Shipping a Three-Sided Delivery Platform on Cloudflare
A case study of building a three-sided delivery platform — merchant, ops, and driver apps in one type-safe monorepo on Cloudflare D1, with a ten-state parcel lifecycle, photo proof of delivery, and MNT invoicing.
1. Three Users, One Parcel
Most delivery products fail on day one because they are designed around a single user. This one has three, with different incentives:
| Role | Cares about | Surface |
|---|---|---|
| Merchant | Registering parcels fast, watching the active flow, understanding the invoice | Parcels board and invoices |
| Ops | Warehouse queue, intake, returns, zone-based driver assignment | Warehouse flow board |
| Driver | Today's pickups, the next stop, proof upload | Pickups and deliveries queues |
The native app (Expo + expo-router) ships all three as separate route groups — (merchant), (ops), (driver) — each with its own tabs, gated by the role on the session. The web app (React + TanStack Router) is deliberately thin: sign-in and a protected dashboard. The product lives on the phone, because that is where the work happens.
2. The Monorepo Layout
The repo is a Turborepo monorepo on Bun, scaffolded from Better-T-Stack, with Biome for lint and format:
apps/native— React Native via Expo; file-based routing with expo-router; Reanimated for transitions and haptics; Mongolian UI copy throughout.apps/web— React + TanStack Router + shadcn/ui + Tailwind; auth screens and a protected dashboard.apps/server— an Elysia API running as a Cloudflare Worker, provisioned by Alchemy with D1 (database), KV (cache), and R2 (proof photos) bindings.packages/db— the Drizzle schema over SQLite/D1.packages/api— the entire HTTP contract plus an Eden Treaty typed client.packages/auth— Better Auth configured with the three roles.packages/env/packages/infra— typed env access and Alchemy infrastructure-as-code.
3. The Parcel Is the Product
The whole business fits in one status enum with ten values:
submitted → picked_up → at_warehouse → assigned → out_for_delivery → delivered, plus the unhappy paths: delivery_failed, returned_to_warehouse, returned_to_shop, cancelled.
Two design decisions do most of the work here:
1. Failures are first-class, not exceptions. A failed delivery records a structured reason — recipient_unreachable, bad_address, recipient_unavailable, recipient_refused, other — plus a free-text note. That is the difference between "the driver says he tried" and data you can run operations on.
2. The status column is the present; the history table is the truth. Every transition writes a parcel_status_history row: from-status, to-status, the acting user and their role, an optional note and failure reason, and a timestamp. When a merchant asks "who marked this delivered?" or an invoice is disputed, the answer is a query, not a forensic exercise.
4. Zones, Assignments, and Proof
Between the warehouse and the doorstep sit three more concepts:
- Delivery zones — named, ordered, toggleable areas. Parcels belong to a zone; drivers carry a default zone; assignments are scoped to a zone. Zones are the entire dispatch model — deliberately not a map.
- Driver assignments — an active-assignment row per parcel/driver pair. Reassignment preserves history: the old row closes (
isActive = false,completedAtset), a new row opens. - Proof of delivery — the driver requests a presigned upload URL, PUTs the photo straight to R2, and the API stores the object key. The image never transits the Worker, and the proof outlives the delivery.
5. Money: Business Rules as Schema
Merchants pay a delivery fee per parcel (default 8,000 MNT) on a weekly or biweekly cadence. Invoicing is two tables — invoice (period, cadence, total, open/paid) and invoice_line (one per billable parcel, with amount and description).
The interesting part is what the indexes say:
invoice_line.parcel_idis unique — a parcel can be billed exactly once, ever. Double-billing is not prevented by careful code; it is prevented by the database.(merchant_account, period_start, period_end)is unique — the same period can never be invoiced twice.
A billableAt timestamp on the parcel decides which period it lands in — set once when the parcel becomes billable, never rewritten.
6. Onboarding: Applications, Not Admin Rows
Merchants do not appear through an admin panel. They apply from the auth screen: shop name, owner, phone, pickup address, and an optional note. Ops reviews the queue and approves or rejects with a reason; approval materializes a merchant_account linked back to its application. The pattern — apply, review, activate — keeps onboarding self-serve while ops keeps control.
7. The Driver App, Screen by Screen
The driver side is where a delivery product lives or dies, so here it is the way a driver experiences it.
Driver home — today's earnings, the daily goal, and the queue of available orders
The home screen is a workday dashboard: today's earnings in tugrik, progress toward the daily goal, and three queues — available, delivering, done. An available order card shows everything needed to decide: merchant, address, distance, pickup time, item count, and the income for the run, with a one-tap accept.
Active delivery — drop-off distance and address, ordered items, the customer's note, then map and complete
An active delivery stacks information in the order it gets used: distance and address first, then the ordered items, then the customer's note — "the lift is not working, please use the stairs." Completing the run goes through the map, a small forcing function that keeps the driver honest about actually being at the address.
Completed delivery — the run's income, customer contact, and order contents
Completed runs stay browsable: income earned, a call button for the customer, the drop-off address, and the items. History a driver can actually read is also what makes disputes cheap to resolve.
Notification center — new orders, customer calls, payouts, system windows, and cancellations
The notification center carries the operational pulse: new orders, incoming customer calls, payout transfers, system maintenance windows, and cancellations — each with its own visual weight.
Driver profile — lifetime stats, rating, withdrawals, and dark mode
And the profile is the driver's resume and wallet: lifetime deliveries, rating, reviews, withdrawal to a bank account — wrapped in a dark-first UI made for sunlight and battery life.
8. What I Deliberately Left Out
- No live fleet tracking. The map exists to navigate the driver to the address — nobody watches moving dots on the ops side. Zones plus status history cover the operational need at a fraction of the complexity; a live ops map is a v2 conversation.
- No payment gateway in v1. The payout moments in the app are the target experience; actual settlement runs through bank transfers for now. Automating it earlier would have bought nothing.
- No customer-facing app. The recipient is a name, phone, and address on the parcel; the merchant and driver are the users.
- No desktop ops console. The work is mobile; a web dashboard beyond auth would have been building the wrong thing first.
9. What I Would Take Into the Next Project
- Model the lifecycle before the screens. The ten-state enum was the product spec; every screen is a projection of it.
- Write the audit trail on day one. History tables are cheap to add early and miserable to backfill.
- Let the contract travel. One
Apptype from Elysia, one Treaty client everywhere — the cheapest integration insurance I know. - Put money rules in the schema. Unique indexes do not have bad days.