AI and agents6 filesInfrastructure2 filesDevelopment5 filesAbout4 files

Caerus — car rental in Montenegro, from separation to growth

  • Next.js
  • payments
  • SEO

Caerus — car rental in Montenegro, from separation to growth

The partner site lived inside a shared aggregator: in production both products ran the same binary against the same database and differed only by environment variables, with no tenancy in the schema. Once the aggregator was down to a single partner with no new ones coming, the product had to be separated — without taking a working business down along the way.

The split

I started with a production audit: a map of processes, the differences between configurations, what controls what. Took a snapshot as the rollback point, and only then separated them. In a setup like this the data is not the dangerous part — the implicit couplings are: a shared process, shared environment variables, a shared deploy. Until they are written down, any separation is done blind.

Since then I run the product alone: all 87 commits in the project are mine. Next.js 14 with React 18 and TypeScript, Express with Prisma over PostgreSQL, six languages via a URL prefix, JWT in cookies, deploys through GitHub Actions onto PM2.

What the product does

A catalog with filters and price calculation, booking with availability checks, a partner dashboard, an admin panel. Contracts are generated as PDFs and emails go through a dedicated service. Online payment was removed from the customer flow at the client’s request; the Stripe and PayPal integrations remain in place and configured.

A separate part of the work is organic traffic: human- and search-friendly URLs, pages targeting queries like “car rental in Montenegro”, structured data and meta tags editable from the admin panel without a deploy.

Three bugs that show what production is like

A search crawler took the catalog down. TypeError: e.data.map is not a function arrived only with Baiduspider. The root cause was not in the parser: an axios interceptor returned error.response instead of throwing, so on failure data could be anything and the next .map() crashed. I fixed it in two places at once — a guard on each of the three parallel requests and a type check before iterating — because that anti-pattern hits every consumer of the interceptor, not one page.

An infinite loop in mobile filters. A RangeError when selecting automatic transmission in the Budva catalog, reproducible on iPhone. The cascade: setValue inside an effect triggered the form watcher, the watcher triggered submit, and submit wrote values again. The fix is not a rewrite but a re-entry guard plus an initialization flag with a timer cleared on effect teardown.

A voucher with the wrong time. A customer books 10:00 and the PDF and email say 08:00. The production process runs in UTC, and unlike every other path, the PDF and emails are rendered on the backend without converting the date into the city’s timezone. I added the conversion and a test covering a daylight saving transition, then applied it to the voucher and the confirmation and cancellation emails. I deliberately left the frontend alone: it already shows the correct local time through a backend offset, and “fixing” it there would double-convert.

The site is live and makes money for the client.