Last-Mile Delivery Platform.
Five apps on one realtime backend, with money reconciled to the penny.
A UK startup needed software to run last-mile delivery as a franchise business. Largely solo, I designed and built five connected apps on one shared Postgres backend, plus the public website and the server it all runs on.
- Role
- Lead engineer · contract
- Team
- Largely solo · led one associate
- Time
- Jul 2025 - Oct 2026
- Stack
- React · React Native · PostgreSQL · Supabase
- Access
- Private: confidential client (NDA)
- 5connected apps
- 1,000+automated tests
- 156database migrations
- 0rows lost in the rebuild
- Overview: Five apps for four kinds of user, all on one Postgres database in real time. Restaurants pick one of two: the full business suite, or a lite portal that just books a rider.
- Full suite: A restaurant on the full suite runs everything from its dashboard: the kitchen workflow, its own in-house riders alongside the freelance pool, and its reports. Next, follow one order from the lite portal.
- Book: A restaurant on the lite portal books a rider by saying when the food will be ready: one screen, pay per delivery. The submission is idempotent, so a double-tap can't book twice.
- Route: The restaurant was matched to its franchise when it signed up, so every order it books belongs to that franchise. Territories can't overlap and each postcode district has one owner, so no address can match two.
- Release: Ahead of the ready time, the backend releases the job to that franchise's freelance riders as an offer.
- Claim: A rider claims it through a server-side function that enforces active-order and cash limits. The first claim wins: the order is locked first, in one fixed order, so a rival gets a clean refusal, never a deadlock.
- Track: Background GPS is sent in batches, and the restaurant follows its rider live on a map.
- Deliver: Proof of delivery (a photo, GPS position and timestamp) goes to the backend, and the restaurant can open the signed photo.
- Settle: It's paid from the restaurant's prepaid wallet in one double-entry ledger, and the rider's earnings, the franchise's payouts and the admin's finance view all read from that same ledger.
Overview. Five apps for four kinds of user, all on one Postgres database in real time. Restaurants pick one of two: the full business suite, or a lite portal that just books a rider.
Key decisions.
A ledger the database refuses to unbalance
I moved wallet balances into a double-entry ledger whose postings must sum to zero, enforced by a database trigger, instead of storing and patching them in app code, where copies drift apart as more apps write to them.
The ledgersimplified · example values3 legs - Post: Every money movement is one posting with at least two legs. Here a database function settles one delivery, split three ways.
- Check: A deferred trigger checks that the legs sum to zero before the transaction commits. Balanced, so it's written.
- Retry: If settling fails at delivery, the order stays delivered and an hourly sweep settles it later. Each leg's idempotency key turns a repeat of a posting that already landed (like a7f3) into a no-op, so nothing is ever paid twice.
- Unbalanced: Say a posting arrives with a leg missing. The legs no longer sum to zero, so the database rejects the whole posting. Nothing is half-written.
- Balance: Balances aren't stored and patched in app code. They're the sum of the postings, so they can't drift.
Post. Every money movement is one posting with at least two legs. Here a database function settles one delivery, split three ways.
Security in the database, not just the apps
I put tenant isolation in row-level security instead of relying on every app to remember a filter: 148 policies, with trusted helper functions where a policy would otherwise loop back on itself. One missed check in an app can't leak another customer's data.
Tenant isolationsimplified · example values - Ask: North's portal asks for its riders. The rule that keeps tenants apart isn't in this query. It's in the database.
- Policy: Row-level security runs inside Postgres and works out who is asking. No app can skip it.
- Rows: Only North's rows come back. South's never leave the database, even if an app forgets a filter.
- Write: North tries to suspend South's rider R-13. The policy matches zero rows, and the portal shows that as an error instead of failing silently.
Ask. North's portal asks for its riders. The rule that keeps tenants apart isn't in this query. It's in the database.
One realtime channel that resyncs after every gap
In the three realtime apps (the dashboard, the lite portal and the courier app), I gave each scope one shared channel, with backoff and a resync on reconnect, instead of every component opening its own, which duplicated traffic and silently missed events.
Realtime that recoverssimplified · example valuesconnected - Live: Order changes stream from the database through one shared channel per scope. One shared hook replaced three hand-rolled copies, each with its own bugs.
- Gap: The tablet sleeps or the network drops. Realtime replays nothing across a gap, so these two updates never arrive.
- Wake: When the network returns or the tablet wakes, the shared hook reconnects at once, backing off if it fails.
- Resync: Then it re-fetches the board and picks up the two updates it missed. Nothing is silently lost.
Live. Order changes stream from the database through one shared channel per scope. One shared hook replaced three hand-rolled copies, each with its own bugs.
An offline queue that won't confirm cash before the pickup syncs
I made the courier app queue pickups and cash confirmations locally, in dependency order, retrying network errors on the next reconnect, instead of firing requests and hoping. A cash confirmation fails cleanly if its pickup is rejected or can't be synced.
The courier's offline queuesimplified · example valuesoffline - Offline: The rider loses signal mid-delivery. Pickups and cash confirmations go into a local queue instead of failing.
- Pickup: They mark the order picked up. It's queued, not sent.
- Cash: They confirm £18.50 in cash. That action depends on the pickup, so it can never be sent before it.
- Sync: Signal returns and the queue drains in order. A network error pauses it until the next reconnect, retrying up to 10 times.
- Cash sent: Only once the pickup has landed does the cash confirmation go.
- What if: If the server rejects a pickup (say the order was cancelled), that conflict fails at once, and its cash confirmation fails with it. Cash is never confirmed for a pickup that didn't happen.
Offline. The rider loses signal mid-delivery. Pickups and cash confirmations go into a local queue instead of failing.
What I'd change
Audit the schema before building apps on top of it, not after.
Background
A UK startup wanted to run last-mile delivery as a franchise business: restaurants dispatch their own orders, regional franchises run the couriers in their territory, and the platform handles the routing and the money in between. Over more than a year I designed and built it as five connected apps on a single realtime backend, plus the public website that sells it and the server it all runs on.
Restaurants can join in two ways: a full business suite for one that runs its kitchen, its own riders and its reporting on the platform, or a lightweight portal for one that just wants a rider: sign up, say when the food is ready, done. The courier on the road, the franchise owner overseeing a territory and the admin watching the whole network each have their own app too, and all five read and write the same Postgres database, in real time, under strict per-tenant isolation. The hard part was never any one screen; it was keeping money, live location and tenancy correct across all of them at once.
Each part in detail
Retailer Operations Dashboard
Web · React 19 + ViteThe full business suite a restaurant lives in: every order from kitchen to doorstep, its own riders and the freelance pool, the cash, and the numbers.
- Order lifecycle enforced by a table of legal transitions (draft through kitchen, dispatch and delivery), with cancellation, dispute and refund paths
- Kitchen workflow (pending → cooking → cooked → packed) with realtime status
- In-house fleet management plus a freelancer marketplace with offers, counter-offers and subscription gating
- Live courier tracking with routes on OpenStreetMap (Leaflet)
- Delivery price from real road distance, locked in when the order is created
- Prepaid wallet with Stripe top-ups and billing portal, cash-on-delivery reconciliation, disputes with evidence, and exportable reports
- Sign-up checked against franchise territories (PostGIS), plus the restaurant's own radius zones for its delivery charge, minimum order and delivery time
Hardest part
Money that can't drift. As the platform grew to five apps, the same balance ended up stored in four places that could drift apart, so I redesigned money as a double-entry ledger: every posting needs at least two legs that sum to zero, each leg carries an idempotency key, and a deferred database trigger rejects anything unbalanced. I created that trigger before migrating the old data, so the backfill ran under the same rule as production, and it reconciled to the penny.
React 19 · Vite · TanStack Query · Stripe · Leaflet · PostGIS · Supabase
Express Dispatch Portal
Web · React 19 + ViteThe lightweight way in, for a restaurant that only wants riders: sign up fast, then book a delivery on one screen in under 30 seconds. No kitchen boards, no fleet to run, no subscription.
- Fast sign-up, self-serve or recruited by the local franchise, with the delivery area checked up front
- Pay per delivery from a prepaid wallet; jobs go to the franchise's freelance riders
- Ready-time keys (“Now” plus the restaurant's own prep presets) instead of any rider-assignment UI; the backend releases the job to riders ahead of the ready time
- Postcode lookup with a map-pin fallback, a saved address book, a live price quote, a distance cap and a wallet-balance check
- Live board and map, a scheduled queue, and a detail view with live rider location and a signed proof-of-delivery photo
- Cancel before pickup, mark the food ready early, or push the ready time back
- Built for arm's-length use on a tablet: state is never shown by colour alone, and touch targets are designed to 46px
- Specified before it was built: I wrote its requirements spec and a 21-decision record, then traced 76 business rules, acceptance criteria and edge cases to code and tests
Hardest part
Realtime that silently lies. Three hand-rolled subscriptions each had their own bugs: a late “closed” signal from an old channel could flash a false “connection lost” banner, and a reconnect never re-fetched what was missed, because realtime replays nothing across a gap. I replaced them with one shared channel hook that resyncs after every gap and reconnects the moment the network returns or the tablet wakes. Submission is idempotent end to end, so a double-tap mid-service can never create two bookings.
React 19 · Vite · TanStack Query · Supabase Realtime · Leaflet · Vitest
Courier App
Mobile · React Native + ExpoWhat riders carry: go on duty, accept a job, navigate, prove delivery and get paid, and keep confirming pickups and cash when the signal drops.
- Multi-step onboarding with document upload and admin verification
- Pushed delivery offers plus a bid pool, claimed through server-side functions that enforce active-order and cash limits and lock the order first, in one fixed order, so the first claim wins and the other rider gets a clean refusal, never a deadlock
- Pickup → delivered flow with cash collection and photo + GPS + timestamp proof of delivery
- Background GPS tracking via a foreground-service task with batched location pings
- Earnings from the shared ledger, payouts, cash-in-hand, and disputes with evidence and a response deadline
Hardest part
Offline-first correctness: an MMKV-backed action queue with dependency ordering (a cash confirmation auto-fails if its pickup fails) and conflict semantics (network errors pause the queue and retry on the next reconnect, up to 10 times; state conflicts fail immediately), draining on reconnect. Live location survives backgrounding and flaky networks by batching GPS points and re-merging failed batches in timestamp order so the trail never scrambles.
React Native · Expo · Zustand · MMKV · Supabase Realtime · Maestro
Franchise Management Portal
Web · React 19 + ViteA franchise owner's control panel over their whole territory: outlets, riders, orders, billing, payouts and disputes, scoped to only what's theirs.
- Franchise-scoped KPI dashboard
- Outlet/retailer CRUD that provisions the auth user and record, enriched with live order, rider and subscription counts
- 5-step rider onboarding with client-side image compression and private, signed document storage
- Order oversight, plus read-only views of its businesses' subscriptions and its riders' payouts from the shared ledger
- Dispute decisions with a rider response window, and a unified pending-approval queue
Hardest part
Multi-tenant isolation in two layers: a franchise_id on every tenant-owned record (orders, businesses, riders, disputes), guarded by Postgres row-level-security policies, plus a client-side filter so a cross-tenant write returns zero rows and surfaces as an error. Every route checks the account's status, accounts are created on a separate client so they can never replace the owner's session, and each rider gets a password of their own. I took its catalogued defects from 16 to 1.
React 19 · Vite · Supabase (RLS) · Stripe
Admin Console
Web · React 19 + ViteThe platform's command centre: approve franchises, draw the territories that decide which franchise serves each restaurant, and watch the money and the risk.
- Platform-wide KPIs, franchise approval lifecycle, and subscription/MRR oversight
- Per-franchise revenue (GMV, commission, MRR), platform growth analytics, and a disputes & penalties console
- Territory drawing on Google Maps, backed by postcode districts that decide which franchise a restaurant joins, and so who handles every order it books
- Finance view of the ledger with editable rate cards, plus payouts and dispute decisions
- Network explorer with a detail page for every franchise, retailer and courier, and one search across all of them
- Courier-performance alerts with admin-set thresholds, and CSV exports
Hardest part
Deciding which franchise a restaurant belongs to. Territories started as hand-drawn PostGIS polygons with a point-in-polygon lookup; I redesigned them around postcode districts, each unique across the whole platform: the lookup matches by district first and falls back to the drawn shape, and the database refuses overlapping territories, so no address can match two franchises. A restaurant is matched when it signs up, and every order it books belongs to that franchise. On the data side, every page moved to server-side paging with filters kept in the URL, closing 15 of its 16 catalogued defects along the way.
React 19 · TanStack Query + Table · Google Maps · Terra Draw · Supabase · postcodes.io
How it was built
Rebuilding the backend they share
All five apps run on one Postgres database: a single realtime layer, one money ledger, and a multi-tenant schema locked down with row-level security. As the apps grew, I audited a full dump of the shared database and rebuilt it in place across 156 migrations (a double-entry ledger, a table of legal order transitions, rewritten security policies, private storage and scheduled jobs), adding first, backfilling second, and only dropping what no app still used, without losing a single row. I then squashed the history into a single baseline and proved it identical to the database it replaced: 2,002 database objects, zero differences.
Locking the backend down
As five apps grew on one schema, I audited and hardened the whole backend. Payment secrets live only in server-side functions, behind a hand-verified, idempotent webhook. File storage is private, served through short-lived signed URLs. Admin access is an explicit check, never a default. And 148 row-level-security policies enforce tenant isolation in the database itself; the ones that would otherwise loop back on themselves go through a small set of trusted helper functions.
Hosting and deploys
I moved the platform off managed hosting onto a single hardened server I set up: self-hosted Supabase, every app in its own container behind nginx and Cloudflare, and SSH reachable only over a private network. Every web app ships through the same pipeline: secret scanning, a container build, a push to a registry, and a deploy that checks the running image matches the commit. Database changes are rebuilt from zero on every pull request and only reach production after an automatic backup. Deploys refuse to run against a different Postgres version from the one tested, and the migration tool is pinned to a release that runs each migration in a transaction.
The public website
I also built the marketing site that sells the platform to restaurants and franchise operators and recruits couriers: about 40 statically rendered pages, a lead pipeline that never claims a form was sent when it wasn't, and structured data that only states facts the business has confirmed. When it pinned a CPU core at 100% in production, I traced it with system-level profiling to a known framework bug and shipped a workaround.
My role
I designed and built all five apps, the shared backend (the Postgres schema, the row-level-security policies, the database functions and the realtime architecture), the public website, and the infrastructure it all runs on. I did it largely solo, leading one associate in the final months. I took the client on in Jul 2025 and saw it through to hand-over in Oct 2026. The work is under NDA, so it's described here by function and architecture only, without the client or product names.
Highlights
- 5 connected apps on one shared Postgres backend, plus the public website
- Double-entry wallet ledger whose balance rule is enforced by the database itself
- Shared database rebuilt across 156 migrations with 0 rows lost, then proved identical as one baseline: 2,002 objects, 0 differences
- Realtime order and courier tracking built to recover from dropped connections
- Offline-first courier app with background GPS and an ordered action queue
- Rider claims that lock the order first, in one fixed order: the first claim wins, and a rival gets a clean refusal instead of a deadlock
- Every app on a database client typed from the generated schema, so a breaking schema change fails the build instead of failing silently
- 1,000+ automated tests, including mutation testing and contract tests against real security policies
- Self-hosted Supabase, Docker and nginx on a hardened server I set up
- Every web deploy secret-scanned, containerised and verified against its commit
Full stack
Frontend & mobile
React 19 · React Native · Expo · Next.js · Vite · TypeScript · Tailwind CSS · TanStack Query · Zustand
Backend & data
Supabase · PostgreSQL · PostGIS · Row-Level Security · Edge Functions · Realtime · pg_cron
Payments & maps
Stripe · Leaflet · Google Maps · OSRM · postcodes.io
Infrastructure
Docker · nginx · GitHub Actions · Cloudflare · Tailscale · Linux VPS
Quality
Vitest · Jest · Maestro · Stryker · Sentry
Want to talk about this build?
I'm open to remote full-time roles and freelance work, and happy to walk through the decisions behind it.