Contact us

The stack we use to ship Android and iOS apps with Expo

Eduardo Leiva, Founder, Weikiu

Most posts about mobile stacks are lists of libraries. This one is different: it's the stack of an app we actually run in production — a field app used every day by truck drivers at a logistics operator, on their own phones, in places where the signal comes and goes.

A truck driver's hands holding a phone against the steering wheel, cab parked with the road ahead.

The requirements were the boring kind that matter: it has to work on Android and iOS, on cheap phones, with bad connectivity, and we have to be able to fix things fast without asking anyone to reinstall anything.

Here's what we chose and why.

One codebase: Expo + React Native + TypeScript

The app is Expo on top of React Native, written entirely in TypeScript. One codebase produces the Android app and the iOS app.

For a client, the argument is simple: two native codebases means roughly two teams, two backlogs, and two chances for the platforms to drift apart. With one codebase a new feature ships to every driver at the same time, and there's a single place to fix a bug.

The trade-off is real but narrow. If your product is a game or leans hard on platform-specific hardware, native is still the right call. For business apps — forms, lists, photos, documents, status changes, notifications — Expo covers it, and it covers it with real native components, not a website in a shell.

Routing: expo-router with typed routes

Navigation uses expo-router, which is file-based: a file under app/ is a screen. The structure of that app reads like the product:

app/
  (auth)/login.tsx
  (tabs)/index.tsx        // today's trips
  (tabs)/shipments.tsx
  (tabs)/remitos.tsx      // delivery notes
  (tabs)/profile.tsx

Anyone who has worked with the Next.js App Router feels at home immediately, which matters when the same team also builds the web side of the product. We turn on typed routes so a typo in a navigation path is a compile error, not a screen that silently does nothing.

Styling: NativeWind

NativeWind brings Tailwind's class names to React Native. That's not a fashion choice — the same product has a web dashboard built with Tailwind, so the team writes the same styling vocabulary on both sides and moves between them without a context switch.

Auth that survives real life

This is where field apps usually fall apart. A driver's phone dies, restarts, loses signal in the middle of a form. The rules we settled on:

  • The session is restored before anything renders. The splash screen stays up until we've read the token from storage and checked it's still valid, so nobody ever sees a flash of the login screen while already logged in.
  • The token lives in secure storage (expo-secure-store, which is Keychain on iOS and Keystore on Android), not in plain local storage.
  • The CSRF token is never persisted. It's derived in memory when the session is restored.
  • One global handler for 401s. Anywhere in the app, a rejected request clears the session and sends the user to login once — instead of ten screens each inventing their own version of that logic.
  • An auth guard in the root layout. The redirect rule lives in exactly one place: logged out and not on login means go to login; logged in and on login means go to the app.

None of this is exotic. It's just decided once, at the root, instead of being re-improvised per screen.

Shipping fixes: OTA updates and a minimum version

This is the pairing I recommend to every client, because each half covers the other's blind spot.

Over-the-air updates (expo-updates with EAS Update) let us push JavaScript changes straight to installed apps. A copy fix, a wrong validation, a broken calculation — minutes, no store review, nothing for the user to do.

But OTA can't ship a change that touches native code, and it can't force anyone to restart. So the app also asks the backend, at startup, for the minimum supported version. If the installed app is below it, a blocking modal appears with a link to the store. That endpoint is public — it's checked before login — because the most dangerous case is an outdated app talking to a newer API and quietly writing bad data.

Together they cover both scenarios: small fixes fly out silently, and the version floor is something we control from the server, not something we have to beg users to do.

A remote switch for risky features

The app pulls a small config object from the backend after login — feature flags, essentially. When we rolled out a new module, it shipped behind a flag that was off. We turned it on for real users when the backend side was ready, with no new release involved. If it had gone wrong, turning it off would have taken one API call.

For a client this is worth more than it sounds: the release date and the launch date stop being the same date.

Documents: presigned uploads straight to S3

Drivers photograph delivery notes and upload documents constantly. The naive version sends the file to your API and the API forwards it to storage — which means your API pays for every byte, twice, and times out on a bad connection.

Instead the app asks the API for a presigned upload URL, uploads the file directly to S3, and then tells the API "here's the key, save the record." The file never passes through our server. Downloads work the same way in reverse, with signed URLs that expire.

Knowing when something breaks

Sentry is wired in and enabled in production only, with trace sampling turned down so it stays cheap. In practice this is the difference between "a driver mentions the app crashed sometime last week" and a stack trace with the device model, the app version, and the exact line.

Post-launch, this is the single most valuable thing in the stack. It's also the part most quotes leave out.

Building and publishing: EAS

Builds and store submissions run on EAS, with separate profiles: a development build with the dev client, an internal preview channel for the client to test on real phones, and production for the stores. Each profile carries its own API URL, so testers hit the staging backend and can't touch production data by accident.

Top tip

Keep your build secrets in EAS environment variables rather than committed config files. Anything in the repo travels with the repo — including into the hands of the next developer who joins.

What this costs you as a client

The honest summary of choosing this stack:

  • One budget instead of two for Android and iOS.
  • Fixes in minutes, not in review queues, for anything that doesn't touch native code.
  • A release process you don't have to learn. Certificates, store listings, privacy forms and review replies are our problem.
  • An app that keeps working after launch, because monitoring, feature flags and version enforcement were designed in from day one rather than bolted on after the first incident.

If you're weighing an app for your business, we're happy to tell you what it would take — and whether an app is actually the right answer, because sometimes it isn't. You can read more about how we build mobile apps, or just get in touch and describe what your users need to do from their phones.

More articles

Create Your First World in Minecraft: A Workshop for Roblox Fans and Beginners

A hands-on workshop to learn how to create your first Minecraft world from scratch, ideal if you are coming from Roblox or starting with no experience. Available in Chivilcoy or remotely.

Read more

How to Automate Your Business with AI (Without Hiring a Team)

Most businesses are still doing manually what could run on autopilot. Here are the five areas where AI automation delivers the fastest ROI — and how to get started without a huge budget.

Read more

Tell us more about your projects

Our Offices

  • Chivilcoy
    Calle 510
    6620, Chivilcoy, Buenos Aires