I have shipped five production mobile apps. Four are Expo and React Native. One is Flutter. The honest conclusion is not a framework name, and it took the hardest of those five to see it clearly.
The apps are Vijaya in Flutter, and Eazyera, the mobile half of Trax, the Mojo-360 app, and Zesty in Expo. Zesty is currently in Play Store testing and is by a distance the most demanding of them, because it is not one app. It is a staff operations console and a customer ordering app sharing one binary and one backend.
This is what I actually learned about cross-platform mobile development from building both, rather than from reading benchmarks.
Five apps, five different jobs
They look similar on a portfolio page and behave nothing alike in production.
Vijaya
Patients book tests, schedule home collection, pay, read reports
Flutter, Android-first, all age groups
Eazyera
Clients book photographers and videographers
Expo, three roles, chat and push
Trax mobile
Staff and security move stock and clear gate passes
Expo, scanning on a weak signal
Mojo-360
Audiences stream video, subscribe, watch offline
Expo Router, cached playback, PiP
Zesty
Restaurant staff run service, guests order from the table
Expo, two products, one binary
Only one is a media app. Only one is a marketplace. Two are internal tools wearing a consumer coat. The stack question looks different for each, which is why "which is better" was never the right question.
Zesty is the case that taught me the most
Zesty's mobile app lives in apps/mobile inside the same Turborepo as the web product. It mirrors the web app across two surfaces that share almost nothing in their user experience.
The operator surface covers roughly eighteen route families: dashboard, orders, kitchen display, billing, tables, menu, inventory, expenses, point of sale, customers, staff, alerts, analytics, sales, franchise, settings, the AI assistant, and account. The customer surface is a different product entirely: scan a QR code at the table, browse that restaurant's menu, add to a cart, check out, track the order.
Both talk to the same backend through TanStack Query over the same typed procedures the web app uses. Authentication runs through the Better Auth React Native client with bearer tokens held in the device keystore rather than plain storage. Navigation is not hardcoded per platform: a database-backed capability catalog drives which routes, icons, and permissions a given user sees, and web and mobile both read from it. Add a capability once and both surfaces learn about it.
That last decision is the one I would repeat on every project. When permissions live in a catalog rather than in two navigation files, a new role does not mean a mobile release that quietly forgot one menu item.
Parity is not a list of routes
Here is the mistake I want to save you from, because we made it and then wrote a rule against it.
We have a script that audits route parity between web and mobile. It is useful. It is also only an inventory check: it proves a file exists at a path. Early on it was easy to read a green audit as "mobile is done," and it absolutely was not.
The rule we settled on is that a mobile route is complete only when it supports the same core user job and the same backend mutation surface as web. Rendering a list of orders is not order parity. Order parity is reservations, queue summary, marketplace actions that know which provider they came from, history search and filtering, order editing, and creating or appending to an order. Billing parity is not a payments screen. It is verifying a payment, printing a cashier receipt, opening the cash drawer, reviewing pending manual payments, and correctly handling the return trip from a checkout the provider hosted.
- The screen renders and fetches
- The audit script is green
- Happy path demoed on a fast phone
- Errors surface as a generic toast
- Offline behaviour untested
- Same user job as web, end to end
- Same backend mutations reachable
- Empty, error, and loading states designed
- Destructive actions confirm before firing
- Reconnect and offline recovery walked through
That right column is still where our open work sits. Product parity across the priority route families is closed. What remains is evidence: device screenshots for every touched route, and a walk through empty, error, loading, destructive-confirmation, reconnect, and offline-recovery states for every updated flow. That is the unglamorous half of mobile, and it is the half that decides whether a staff member trusts the app during Friday dinner service.
Why Zesty went Expo
By the time Zesty came around the pattern was clear from Eazyera, Trax, and Mojo-360. Each sits next to a web admin that owns the same nouns: orders, tables, transfers, gate passes, subscriptions, roles.
Expo let one team hold one language across all of it. TypeScript in the API, TypeScript on the web, TypeScript in the app. The mobile client consumes the same procedure definitions as the web client, so when an order gains a status it gains it once and both surfaces follow. NativeWind carried over the Tailwind habits. TanStack Query behaves the same on both sides.
Release cadence matters just as much when you are the person doing the releases. Being able to push a JavaScript-only fix without a store round trip is hours back every month for a small team.
The pain is real too. Native modules drift, and an SDK upgrade can eat a week when auth, secure storage, camera, and payments all have to move together. Deep links were harder than expected: a QR code at a table has to open the right restaurant's menu whether the phone has the app installed or not, which meant rewriting links natively and persisting a guest session across that jump. Video was the worst of it on Mojo-360, where player controls, picture-in-picture, background audio, and cached playback all sit exactly where JavaScript meets platform code.
Why Vijaya went Flutter, and would again
Vijaya is a patient companion to an existing web platform and hospital billing system. Patients of every age, on phones that are often three or four years old, booking diagnostic tests, scheduling home sample collection, paying, and opening PDF reports.
Flutter earned its place. The app renders itself, so a long booking form looked and behaved the same on a flagship and on a budget device with a manufacturer skin on top. Date pickers, forms, and report lists stayed smooth. Maps, payments, and Firebase all had solid packages.
What it cost was less visible. Dart is a second language next to a Next.js and Node stack, so the app and the web platform shared an API and nothing else. No shared types, no shared validation, no shared domain helpers. Every model change had to be made twice by hand, and every review needed someone who could read both. On a small team, that split is the real bill, not the framework.
If Vijaya had needed to stay in lockstep with a staff console that changes weekly, I would have chosen differently. It did not. It was the product.
The questions that actually decide it
I stopped comparing feature matrices and started asking five things.
- The app is the product, not a second surface
- UI consistency on cheap Android is a hard requirement
- Heavy custom rendering or animation is the experience
- The client already owns Dart code or Dart people
- The mobile team can be its own team
- A React or Next.js web product shares the domain model
- One team must own web, API, and mobile
- Typed contracts and shared permissions save real time
- Frequent small releases matter more than native polish
- The roadmap is workflow features, not rendering feats
None of them is about performance. Both stacks are fast enough for booking, scanning, chat, billing, and streaming. The tiebreakers are ownership, shared contracts, and release cadence.
There is a third answer worth saying out loud: sometimes neither. If the product is content someone reads once, a responsive web app avoids two stores, two review queues, and an update problem you own forever.
What costs you regardless of stack
The expensive lessons were never about widgets.
OTP as a single screen
Problem: Codes arrive late, users mash resend, and the timer expires mid-typing.
Better move: Treat login as a flow: resend limits, generous expiry, autofill support, and a support fallback.
Tokens in plain storage
Problem: A bearer token sitting in unencrypted key-value storage on a shared staff device.
Better move: Use the platform keystore, and scope the session to an explicit active organization.
Deep links assumed to work
Problem: A table QR opens a browser, or the app, or nothing, depending on install state.
Better move: Rewrite links natively, persist the guest session across the jump, and test both install states.
Payment return forgotten
Problem: The provider flow leaves the app and comes back to the wrong screen or no screen.
Better move: Build an explicit payment bridge, and derive paid state from verification, never from the client.
Permissions requested up front
Problem: Camera prompts on first launch, denied by reflex, the scanner dead with no recovery.
Better move: Ask in context, explain why, and design the denied path as a real screen.
Testing on the good phone
Problem: Smooth on the developer's device, janky on a three-year-old budget Android.
Better move: Keep one deliberately slow device in the loop and test on it before every release.
Scanning deserves its own note. On Trax, barcode reads happened in dim warehouses with scratched labels and gloved hands. No framework fixes that. Torch control, a large scan target, audible confirmation, and manual entry as a first-class fallback do.
Offline, honestly
"Offline support" is the phrase that quietly costs the most, because it means something different in every app.
On Mojo-360 it meant cached playback so a commute did not kill a video. Reads worked offline; anything touching money or subscription state waited for a network. On Trax it meant the floor kept working on a weak signal: record locally, sync when the connection returned, and never show a gate pass as cleared until the server said so. On Zesty it means staff can capture status changes, serve-and-bill, and collect-payment actions in durable order and replay them, with conflicts routed to a human rather than force-applied. On the VEQO field app, the newest Expo project, it means a local queue with an idempotency key on every event so a retry cannot create a second journey record. On Vijaya and Eazyera it mostly meant graceful failure, because a booking that pretends to succeed offline is worse than a clear error.
Five apps, five meanings. Write down which one you mean before anyone builds a cache.
What the store actually asks of you
Getting Zesty into Play Store testing surfaced work that no amount of local development exposes.
Build and release
- Signing, versioning, and a release track that testers can actually install
- A preview build pointed at a real origin, not a laptop on the same network
- Automated smoke and visual scripts that run against that build
- Device screenshots captured per route as reviewable evidence
Behaviour under stress
- Empty, error, and loading states on every screen a tester will open
- Destructive actions confirm, and say what they will destroy
- Reconnect after a dropped network leaves no half-applied state
- Permission denial and camera failure both have a way out
The pattern I would pass on: treat the signoff tooling as product work, not chores. Screenshot and preview signoff scripts are the difference between "we think it works" and an artifact a reviewer can look at.
What I would pick again
Zesty
Expo again, without hesitation. Two surfaces over one typed API and one permission catalog is exactly the case Expo wins.
Trax and Eazyera
Expo again. Role-based workflows beside a web admin, where shared contracts beat native polish.
Mojo-360
Expo again, with more time budgeted for video. The player and offline layer deserved their own milestone.
Vijaya
Flutter again. The app is the product, the audience is Android-first, and consistency mattered more than sharing code.
Lock these before the first screen
Product
- Android-first or iOS-first, and which devices you will actually test on
- The one job the app must do when the network is bad
- Which roles share the app, and where permissions are defined
- Whether this is one product or two sharing a binary
Engineering
- Who maintains this app in year two, and in which language
- How types and API contracts stay in sync with the web product
- Where auth tokens live and how sessions are scoped
- How a hotfix reaches users without a store review
If you cannot answer the maintenance question, the framework debate is decoration.
The part that does not change
Both stacks shipped. Both had a week I would rather forget. What separated the smooth projects from the rough ones was never Dart against TypeScript. It was whether the workflow was clear before the first screen, whether the domain model was shared or duplicated, and whether anyone had defined what "done" meant beyond a route rendering.
For how the backend behind Zesty's mobile app keeps order state honest, read the order lifecycle behind Zesty. For the delivery process this all sits inside, see requirements to production.
Pick the stack your team can still maintain once the launch excitement is gone, then spend the time you saved on parity you can prove.