All articles
August 11, 202612 min read

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.

deliverylogisticsreact-nativeexpoelysiacloudflaredrizzlemonorepo
Shipping a Three-Sided Delivery Platform on Cloudflare

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:

RoleCares aboutSurface
MerchantRegistering parcels fast, watching the active flow, understanding the invoiceParcels board and invoices
OpsWarehouse queue, intake, returns, zone-based driver assignmentWarehouse flow board
DriverToday's pickups, the next stop, proof uploadPickups 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, completedAt set), 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_id is 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 ordersDriver 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 completeActive 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 contentsCompleted 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 cancellationsNotification 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 modeDriver 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 App type 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.