Caerus — car rental in Montenegro, from separation to growth
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.