RAR
A vehicle rental marketplace where renters, hosts, and admins all work against one API.
- Lead developer, two-person engineering team
- 2026
- live
- NestJS, TypeORM, PostgreSQL, React, Flutter, Stripe
- Ring A Rental (RAR Africa), private repositories
- Live
- 4
- 24
- 50
- 36
The web product is live and you can go use it. The repositories belong to the company and stay private, so this is architecture and decisions rather than code. The mobile app is built and running against the same API; store availability is not a claim I will make here until it is confirmed.
RAR rents vehicles in Zimbabwe and Rwanda, in four shapes. Everyday Drive is self-drive by date range. Shuttle Drive includes a driver. Smart Drive is a negotiation, where a customer proposes a price and the host accepts, rejects or counters. Safari Drive is a game-drive vehicle tied to a national park.
All four run on one NestJS API over Postgres, consumed by two independent clients: a React web app that also carries the vendor dashboard and the admin shell, and a Flutter app for iOS and Android. I am the lead developer on all of it, in a two-person engineering team.
Most of the difficulty here is not in any single feature. It comes from one contract serving two clients built at different times by different means, on a product that was already shipping when I arrived.
One API, two clients that agree on almost nothing
The two clients share a server and differ on nearly every detail around it. The
web app keeps its JWT in localStorage and logs you out after fifteen minutes
of inactivity. Mobile keeps its token in the Keychain or Keystore through secure
storage and holds it until you sign out, because an idle timeout on a phone
means logging in again every time you put it in your pocket.
Payments diverge the same way. Web sends you to Stripe’s hosted Checkout and back. Mobile opens the PaymentSheet natively in-app, with hosted Checkout as a fallback, so the two use different endpoints against the same Stripe account: one creates a session, the other a PaymentIntent. KYC splits along the same line, with the browser camera on web and a native passive-liveness and document SDK on mobile, both posting to the same server-side verification.
What has to stay identical is the contract. So a controller signature change is a two-client change, and both are checked before it merges.
Endpoint renames are the clearest case. The bid flow is the bids module and
/api/bids/* is its canonical surface, but the mobile app was written against
an older /smart-drive/offers/* path and still calls it. Keeping the old path
alive is untidy, and the alternative is a backend rename shipping on a Tuesday
and every installed copy of the app breaking until users update, which they do
on their own schedule. The alias stays until mobile migrates, and it is written
down as a thing not to clean up.
The bootstrap ordering everything depends on
The server’s startup file looks unremarkable and is almost entirely load-bearing, because several requirements only work in one specific order.
Stripe verifies webhook signatures against the exact bytes it sent. Any JSON body parser that touches the request first invalidates the signature, and the failure is silent: signature verification fails and bookings never get created. So the default body parser is disabled outright, a raw body parser is scoped to the webhook route alone, and JSON parsing is registered after it.
The same file raises the body limit to 50 MB, because vehicle listings carry photographs from phone cameras and both clients depend on that ceiling. It sets the global API prefix only after static assets are mounted, so uploaded files and the single-page fallback both resolve. It trusts one proxy hop so HTTPS detection works behind Nginx.
None of that is clever, and all of it breaks quietly if reordered, which is why it is documented in place.
Two of the four products are not modules
Smart Drive is the bids module. Safari Drive is not a module at all: it is a
read-only national parks catalogue, two columns on the existing vehicle entity,
validation and filtering in the vehicles controller, and one page on the web
client.
That was deliberate. A safari vehicle is a vehicle with a park attached, not a separate kind of thing, and a separate module would have meant a second listing pipeline, a second availability check, and a second place for the two to drift apart. The cost is that “where does Safari Drive live” has no single answer, which is why it is written down.
I built that product end to end: the schema and its migration, the entity changes, the API surface, and the web page.
Writing a client against a server that keeps moving
The Flutter app has a hand-rolled HTTP client rather than a generated one, and its services normalise responses defensively: helpers that accept a value whether it arrives bare, nested under a data key, or wrapped in an envelope. The image upload helper accepts several different keys because the server has returned several different shapes over the product’s life.
That is accumulated tolerance for past behaviour, not design. The right fix is normalising the response envelope on the server, and until that happens removing the tolerance would break the app against its own history. So the helpers stay, they are documented as deliberate, and the order of work is written next to them: normalise the server, then tighten the client. Tightening first moves the breakage to the users.
Most of leading this codebase is that kind of work. Every change across backend, web and mobile goes through review before it merges, work is ticketed with a branch per ticket so a change traces back to the request that caused it, and the constraints that must not be broken live in the repository rather than in my head.
Where it is
Live, serving real bookings, with the mobile app built for both platforms against the same API.
The gaps: the response envelopes above are still not normalised, the mobile app has no test suite beyond boilerplate, Safari Drive has no mobile client yet, and there is a payment capture bug in the PayPal path, written up with its root cause in the repository’s known-issues register.