← writing

27 Sept 2026 · 3 min read

One developer, four platforms: an office ledger in Expo and Express

Materials, services, purchases and payments, on Android, iOS and the web from one codebase, over a small Express API. How it's put together, and what I'd change today.

The ask was one app for four kinds of office records (materials going out, service jobs, purchases and payments) on Android, iOS and the web. The budget was one developer. The developer was me, so the budget was also my evenings.

The trick to four platforms with one developer is not writing four apps. This is one Expo codebase: Expo Router for navigation, React Native for Android and iOS, and react-native-web with a static export for the browser. The backend is a small Express API over MongoDB. Here's how the pieces meet.

How the office ledger fits together

sign in

photo

image URL

REST, ten entries at a time

Expo app
Android, iOS and web, one codebase

Clerk

Cloudinary
unsigned upload preset

Express API, /api/v0
helmet, compression, rate limit

MongoDB
four collections

Four ledgers, one spine

Each ledger is a tab (Materials, Payments, Purchases, Services) and a Mongoose model. They share a spine: who created the entry, a date, the customer and company, free-text details, a status and an optional photo. Then each adds what only it needs: a challan number and dispatch details for materials, a bill number and the amount still owed for payments, the material used and its cost for purchases, the fault for a service job.

The statuses are where the office actually lives:

The statuses each ledger moves through

Payments and purchases

Services

Materials

Pending

Shipped

Delivered

Pending

Delivered

Pending

Paid

Every list colours its entries by status, so "what's still pending" is a glance rather than a query.

The API is five routes, four times

Each ledger gets the same five REST routes (create, list, read, update, delete) mounted under /api/v0. In front of them sit the boring things that matter: helmet for headers, compression, request logging, and a rate limit of 100 requests per IP every 15 minutes, because an office app does not need to survive a DDoS, but it shouldn't make one easy.

Listing uses the cheapest pagination trick I know. Ask for one more row than you'll show:

const materialEntries = await MaterialEntry.find(query)
  .sort({ createdAt: "desc" })
  .skip(skip)
  .limit(11);

const hasMoreEntries = materialEntries.length === 11;

res.status(200).json({
  entries: materialEntries.slice(0, 10),
  hasMoreEntries,
});

If the eleventh row exists, there's another page. No count query, no second round trip, and the app keeps loading while hasMoreEntries stays true.

Photos never touch the API

Entries can carry a photo, and photos are megabytes. The app uploads them straight from the phone to Cloudinary with an unsigned upload preset and stores only the returned URL on the entry. The Express server never sees an image, which is the cheapest way to keep a small server small.

What I'd change today

With two more years behind me, here's what I'd change:

One developer, four platforms. The mercy was optional.

The code is on GitHub.

Written by Jay Pokale — researcher at IIT Hyderabad, top 1% competitive programmer, author of Chisle. Replies faster than his CI.