← All notes
Product & Platform Engineering8 min

A Next.js feature flag is not a rollout

Platform talk in 2026 is full of progressive delivery: ship the code dark, open a flag, watch the metrics, roll back if they move. That model fits a single web deploy. BuilderHelp is four surfaces with four release clocks — a Next.js system of record on Vercel, an Expo field app that waits on App Store and Play review, a separately deployed marketing site, and a Python OCR microservice that reads supplier invoices. A flag that only exists in the web app is not a rollout. It is a local preference with a confident name.

Four clocks, one capability

When we add something like a new receipt field, or a change to how a purchase order becomes a budget line, the work rarely lives in one repo. The web app has to store and display it. The field app has to capture it with cold hands and intermittent signal. The OCR service has to extract it from a photographed invoice and post it back without inventing a part number. Marketing usually stays out of it, which is the point of giving it its own deploy.

Each of those surfaces ships on its own cadence. Web and marketing redeploy in minutes. The OCR worker updates when we push the service. The mobile binary updates when Apple and Google finish reviewing it, and a portion of crews stay on the previous build for weeks after that. Progressive delivery literature often treats "the client" as something you can refresh. On a jobsite phone, you cannot.

So the design question is not "how do we hide this behind a flag in React." It is "what does an older field app do when the server starts speaking a newer shape, and what does a newer field app do when the OCR worker has not caught up yet."

Put the gate where every client has to pass

Client-side flags are fine for copy, layout experiments, and UI that never changes the meaning of a record. They are the wrong tool for anything that crosses a surface boundary. If the web dashboard can create a document the Expo app cannot render, or the OCR service posts a line item the API rejects, the flag in Next.js did not protect anyone. It only protected the person looking at the dashboard.

The gate that matters sits in the API the web app owns — the same API the field app and the OCR worker already talk to. Capability checks, accepted payload versions, and explicit "not supported on this client" responses live there. A crew on last month's binary should get a calm, specific failure or a degraded path, not a silent drop or a half-written invoice.

That is less glamorous than a percentage rollout slider. It is also the only place a decision can be enforced for every surface at once. Authorization and money paths already taught us this: if the client has to be trusted to hide something, it is not hidden. Release capability works the same way.

Compatible first, then optional

The practical sequence is boring on purpose. First ship the server in a shape old clients already tolerate: additive fields, defaulted values, handlers that ignore what they do not understand. Then ship the clients that know how to use the new shape. Only after those clients are actually in the wild do you make the new behavior expected rather than optional.

For BuilderHelp that often means the OCR microservice and the web API move first, because both can be redeployed without a store review. The Expo app follows with UI that reads the new fields when present and keeps the old capture flow when they are not. Turning a soft field into a required one is a separate release, scheduled after we can see which app versions are still checking in.

The inverse order is how you manufacture support tickets. Enable the "new invoice flow" flag for ten percent of web users while the field app still posts the old payload, and you have not run a canary. You have created two truths about the same receipt and asked a project manager to reconcile them.

Rollback is a property of the surface

Vercel lets us revert a web deploy quickly. That comfort does not transfer. You cannot yank a binary out of a framer's pocket because Sentry got noisy. You cannot un-print a kitchen ticket, and you cannot un-send a subcontractor payment. Each surface has a different undo story, and the rollout plan has to be written against the worst undo, not the easiest one.

For changes that touch money or budget lines, we treat the web flag as a narrow UI switch at most. The durable controls are reconciliation, idempotent writes, and API responses that refuse a partial update instead of accepting it and hoping a later client repairs it. For changes that only affect dashboard presentation, a web-only flag is enough, and pretending otherwise is ceremony.

Sentry helps only if the event says which surface and which build produced it. An error without an app version and a deploy identity is a story with the names removed. Multi-surface rollouts fail quietly when every client looks like "production" in the dashboard.

What we write down before the flag exists

Before we add a capability that crosses surfaces, we write the boring checklist: which repos must change, which clients may remain on the old behavior and for how long, what an old client sees, and what "roll back" means on each clock. If the honest answer is that mobile cannot roll back, then the server change has to be backward compatible until the binary curve catches up.

We also name the non-goals. A marketing site deploy is not part of a payments rollout. A dashboard animation is not waiting on EAS. Keeping those boundaries explicit is how independent deploys stay an advantage instead of becoming four ways to ship an incomplete feature.

Progressive delivery is still the right instinct. Ship dark where you can, open traffic carefully, watch real use. On a product like BuilderHelp, "dark" means the API and the workers are ready, the old field app still functions, and the flag in Next.js is the last switch you flip — not the first, and never the only one.

Have something to build?

Tell us what you're working on and we'll tell you honestly whether we're the right fit.

Work with us