Shipping a React Native App in 2026: The Expo Production Checklist
React NativeExpoMobile

Shipping a React Native App in 2026: The Expo Production Checklist

Everything between a working simulator build and a live listing on both stores — EAS builds, OTA updates, signing, permissions, crash reporting, and the review rejections that cost me weeks.

HJ
Hassan Javed
August 2026
11 min read

The gap nobody warns you about

Getting a React Native app working on your simulator takes an afternoon. Getting it into the App Store and Play Store takes two weeks the first time, and most of those two weeks are spent on things that have nothing to do with your product.

I have shipped React Native apps for client work over the last three years, including a private client app currently live in both stores. This is the checklist I now run before I tell anyone a build is ready.

Use Expo — the bare vs managed debate is over

In 2026 there is no serious reason to start a new React Native app outside Expo. The old objection — "I need a native module Expo does not support" — died with config plugins and the development build. You get:

EAS Build — cloud builds for iOS without owning a Mac
EAS Update — over-the-air JS updates without a store review
expo-dev-client — a custom dev build with any native module you want
Config plugins — native project changes expressed in app.config.js, so ios/ and android/ stay generated, not hand-edited

Once you hand-edit ios/, you own that folder forever. Avoid it as long as you can.

Project setup that pays off later

Use a dynamic config so environment values are not hardcoded:

jscode
// app.config.js
export default ({ config }) => ({
  ...config,
  name: process.env.APP_VARIANT === "dev" ? "MyApp Dev" : "MyApp",
  slug: "myapp",
  ios: {
    bundleIdentifier:
      process.env.APP_VARIANT === "dev" ? "com.acme.myapp.dev" : "com.acme.myapp",
    supportsTablet: true,
  },
  android: {
    package:
      process.env.APP_VARIANT === "dev" ? "com.acme.myapp.dev" : "com.acme.myapp",
  },
  extra: {
    apiUrl: process.env.API_URL,
    eas: { projectId: process.env.EAS_PROJECT_ID },
  },
});

Separate bundle IDs for dev and production means both apps live on the same device at the same time. Your QA testers will thank you.

The build profiles

jsoncode
{
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal",
      "env": { "APP_VARIANT": "dev" }
    },
    "preview": {
      "distribution": "internal",
      "channel": "preview"
    },
    "production": {
      "autoIncrement": true,
      "channel": "production"
    }
  }
}

autoIncrement on production is not optional. Forgetting to bump the build number is the single most common reason an otherwise-perfect upload gets rejected by App Store Connect, and you only find out after the twelve-minute upload finishes.

Signing, without the ceremony

Let EAS manage your credentials. Run eas credentials once, let it generate the distribution certificate and provisioning profile, and never think about the Keychain again. For Android, EAS generates and stores the upload keystore — but download a backup of that keystore and put it somewhere you will still have in three years. Lose it and you cannot update the app under that package name. Ever.

Permissions: ask late, explain first

Both stores now reject apps that request permissions at launch with no context. The pattern that passes review every time:

1.User taps something that genuinely needs the permission
2.You show your own screen explaining why, with a "Not now" option
3.Only if they agree do you trigger the OS prompt

The OS prompt can be shown once. If a user denies it, you are sending them to Settings — a flow almost nobody completes. Spend the extra screen.

Every NSCameraUsageDescription-style string must describe the actual user benefit. "Access to camera" gets rejected. "Take a photo of a receipt to attach it to an expense" does not.

Use Expo Router. File-based routing, typed routes, and — crucially — it handles the Android hardware back button and iOS swipe-back consistently. Hand-rolled stack navigation gets these wrong in exactly the edge cases reviewers test: deep link into a detail screen, press back, and land on a blank screen instead of the list.

Test every deep link cold — app not running at all. That is the path that breaks.

Performance: the three things that actually matter

1. Use FlashList, not FlatList, for anything over ~50 rows. The difference on mid-range Android is not subtle.

2. Move animations to the UI thread. Reanimated worklets run on the UI thread and keep 60fps even while JS is busy:

jscode
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withSpring,
} from "react-native-reanimated";

const offset = useSharedValue(0);
const style = useAnimatedStyle(() => ({
  transform: [{ translateY: withSpring(offset.value) }],
}));

3. Test on a cheap Android phone. Not a flagship, not the emulator. A three-year-old mid-range device is what a real chunk of your users have, and it will reveal every list rerender and oversized image you shipped.

Crash reporting before launch, not after

Wire Sentry (or Crashlytics) with source maps uploaded on every EAS build, before your first TestFlight invite. A stack trace full of minified bundle offsets is worthless, and you cannot reconstruct source maps for a build you already shipped.

OTA updates: powerful and easy to misuse

EAS Update lets you push JS changes without a store review. The rules I follow:

OTA is for fixes, not features. A feature that needs a native module needs a new build anyway.
Never OTA a change that alters the data your app writes to the server without a matching backend deploy first.
Keep the channel tied to the build profile. Pushing a production JS bundle onto a build compiled against a different native runtime is how you brick a release.
Always have a rollback ready — eas update:rollback takes seconds, panicking takes hours.

The review rejections I have actually gotten

RejectionReal causeFix
Guideline 5.1.1Permission string too vagueRewrite with concrete user benefit
Guideline 2.1Reviewer could not log inShip a demo account in review notes
Guideline 4.2App felt like a website wrapperAdd real native behaviour — offline, push, haptics
Guideline 5.1.1(v)No account deletionIn-app delete flow, not an email link
Play: Data safetyForm did not match the SDKs usedAudit every third-party SDK, refile

Account deletion is the one that surprises teams. If a user can create an account in your app, they must be able to delete it in your app. An email address to write to is not enough.

Pre-submit checklist

Version and build number bumped
Production API URL, not staging, in the production profile
Crash reporting live with source maps
Demo credentials in App Store Connect review notes
Privacy policy URL live and reachable
Data safety and privacy labels match the SDKs you actually ship
Account deletion flow in-app
Tested on a low-end Android device
Tested with airplane mode on — no infinite spinners
Deep links tested from cold start
Screenshots for every required device size

What I would tell my past self

Budget two weeks for the store process on the first release of any app, and two days for every one after that. The first release is where you discover your privacy labels are wrong, your keystore is not backed up, and your reviewer cannot get past the login screen. Every subsequent release is just eas build --auto-submit.

The engineering was never the hard part.

Related Reads

You might also like