← All work

RAR

A vehicle rental marketplace where renters, hosts, and admins all work against one API.

Role
Lead developer, two-person engineering team
Year
2026
Status
live
Built with
NestJS, TypeORM, PostgreSQL, React, Flutter, Stripe
Context
Ring A Rental (RAR Africa), private repositories
Go look
  • 4Rental products
  • 24Backend modules
  • 50Web pages
  • 36App screens

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.

The interesting problems here are not any single feature. They are what happens when one contract has to serve two clients built at different times by different means, on a product that was shipping before I arrived.

One API, two clients that agree on almost nothing

The two clients share a server and disagree about 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 the rule I hold is that a controller signature change is a two-client change, and both are checked before it merges.

The clearest case is an endpoint rename. 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 not tidy. It is much better than the alternative, which 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 and not yours. 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 simply 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. Disabling the default parser looks like an odd choice until you know why it is there.

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. All of it breaks quietly if reordered, which is why it is documented in place rather than left for the next person to rediscover from a support ticket about missing bookings.

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 a deliberate choice and I would make it again. A safari vehicle is a vehicle with a park attached, not a separate kind of thing, and giving it its own module would have meant a second listing pipeline, a second availability check, and a second place for the two to drift apart. The cost of the decision is that “where does Safari Drive live” has no single answer, which is precisely 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 will 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.

I want to be straight about what that is. It is scar tissue, 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 direction of travel is written next to them: normalise the server, then tighten the client, in that order. Tightening first just moves the breakage to the users.

That kind of thing is most of what leading this codebase actually involves. 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 honest 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 documented payment capture bug in the PayPal path that is written up with its root cause in the repository’s known-issues register rather than being quietly carried. I would rather a defect be findable than tidy.