React Native lets you build cross-platform mobile apps from a single codebase, but getting from a working development build to a published app on the App Store and Google Play is a journey of its own. Code signing, build toolchain selection, release mode stability, store compliance, and deployment automation all need to be set up correctly before your app reaches users.
In this blog, we'll walk through the complete process of publishing a React Native application: from choosing between EAS Build and React Native CLI to production build configuration, CI/CD automation with GitHub Actions, and a real-world approach to diagnosing release mode crashes that we encountered on one of our projects.
EAS Build vs React Native CLI
The first decision you'll need to make is which build toolchain to use. Both EAS Build and React Native CLI can produce production-ready binaries, but they serve different needs.
EAS Build is a cloud-based build service from Expo. It compiles your app on remote servers, manages code signing automatically, and supports direct submission to both app stores. You don't need a Mac for iOS builds.
React Native CLI gives you direct access to native Xcode and Gradle projects. Builds run locally or on your own CI infrastructure, giving you full control over the native layer.
- EAS Build allows iOS builds without a Mac, whereas React Native CLI does not.
- Code signing is automated in EAS Build, while React Native CLI requires manual code signing.
- Both EAS Build and React Native CLI provide full native module support.
- EAS Build uses a cloud-based build environment, whereas React Native CLI relies on a local or self-hosted CI environment.
- For OTA updates, EAS Build includes EAS Update as a built-in feature, while React Native CLI depends on CodePush or a custom solution.
- EAS Build offers a free tier with paid priority options, whereas React Native CLI is free but depends on your own hardware.
- CI/CD setup is minimal with EAS Build, while React Native CLI typically requires Fastlane or custom scripts.
- The learning curve for EAS Build is lower, whereas React Native CLI has a higher learning curve.
Choose EAS Build when your project needs managed infrastructure, automated code signing, built-in OTA update support, or when your team doesn't have dedicated macOS machines for iOS builds.
Choose React Native CLI when your app relies on heavy custom native code, you need full control over native project files, you're integrating React Native into an existing native app, or you have existing CI infrastructure like Jenkins or CircleCI with macOS runners.
For new projects without specific native requirements, we'd recommend starting with EAS Build. You can always eject to a bare workflow later as your requirements evolve.
Step 1: Project Setup
The examples in this guide use EAS Build. The same CI/CD principles apply if you're using React Native CLI with Fastlane.
Prerequisites:
- Node.js 18+
- Expo account (expo.dev)
- Apple Developer Program ($99/year)
- Google Play Console ($25 one-time)
Initialize EAS:
npm install -g eas-cli
eas login
eas initConfigure build profiles by creating an eas.json file at your project root:
{
"cli": {
"version": ">= 5.0.0"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal",
"ios": { "simulator": true }
},
"production": {
"autoIncrement": true
}
},
"submit": {
"production": {
"ios": {
"appleId": "your@email.com",
"ascAppId": "your-app-id",
"appleTeamId": "YOUR_TEAM_ID"
},
"android": {
"serviceAccountKeyPath": "./google-service-account.json",
"track": "internal"
}
}
}
} The development profile creates a debug build with the Expo dev client. The preview profile generates an internal distribution build for testing. The production profile creates the store-ready binary, with autoIncrement enabled to prevent build number conflicts during submission.
Step 2: Code Signing
Code signing ties your app binary to your developer identity. Without valid signing, neither Apple nor Google will accept the submission.
iOS: EAS manages distribution certificates and provisioning profiles automatically.
eas credentials --platform iosThis eliminates the need to manually configure certificates through the Apple Developer portal.
Android: Generate a release keystore and store it securely. Never commit it to version control.
keytool -genkeypair -v -storetype PKCS12 \
-keystore release.keystore \
-alias your-app-alias \
-keyalg RSA -keysize 2048 \
-validity 10000We recommend using Google Play App Signing. With this setup, Google holds the signing key and you retain an upload key, which gives you recovery options if the upload key is ever compromised.
Step 3: Production Build and Submission
With code signing configured, trigger the production builds:
# Build for both platforms
eas build --platform all --profile production
# Submit to stores
eas submit --platform ios --latest
eas submit --platform android --latestYou can monitor build progress at expo.dev or directly in your terminal.
Step 4: Debugging Release Mode Crashes
This is the section most publishing guides skip, and the one that cost us the most time on a real project. Everything worked perfectly in debug mode. The app built, installed, and ran on simulators and devices without a single issue. The release build also succeeded and the app installed on the device. But when we tapped the icon, it crashed immediately. No splash screen, no error, nothing. Just a brief flash and back to the home screen.
What We Ran Into
The app crashed before the JavaScript runtime had a chance to initialize. That meant React Native's LogBox never activated, error boundaries never mounted, and remote debugging wasn't an option because the app never reached that point. We had a fully built and installed release binary that simply refused to open, with zero feedback about why.
We spent days on this. Stack Overflow answers were too generic. The crash was specific to our project's configuration, and nothing we found online matched our exact scenario.
Why This Happens
In debug mode, React Native loads the JavaScript bundle from Metro's development server. The bundle is unoptimized, errors are caught and displayed by dev tools, and the environment is generally forgiving. In release mode, the bundle is compiled ahead of time, minified, and embedded into the native binary. Hermes applies bytecode optimizations that can surface issues debug mode hides entirely.
If something fails during bundle loading or early initialization, the app crashes at the native level before any JavaScript-side error handling is active. Common causes include environment variables present in .env but not injected at build time, Firebase configuration files pointing to the wrong project, native modules that load correctly in debug but get stripped by ProGuard on Android, and initialization code that depends on a development server being available.
How We Tackled It
Step 1: Reproduce locally. Before anything else, we confirmed the crash on a local release build. Don't rely on cloud builds for debugging; you need the crash happening on your machine where you can access native logs.
# iOS
npx expo run:ios --configuration Release
# Android
npx expo run:android --variant release Step 2: Add native-level logging. Since JavaScript logs weren't executing, the only way forward was to add logging at the native layer to narrow down where the crash occurred.
For iOS, we added NSLog statements in AppDelegate.m (or AppDelegate.swift) at key lifecycle points:
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
NSLog(@"AppDebug: didFinishLaunchingWithOptions started");
// ... initialization code
NSLog(@"AppDebug: Bundle URL: %@", [[self bundleURL] absoluteString]);
NSLog(@"AppDebug: didFinishLaunchingWithOptions completed");
return YES;
} For Android, we added log statements in MainApplication.java or MainApplication.kt:
import android.util.Log;
@Override
public void onCreate() {
Log.d("AppDebug", "onCreate started");
super.onCreate();
Log.d("AppDebug", "super.onCreate completed");
Log.d("AppDebug", "onCreate completed");
} Then read the output:
# Android
adb logcat | grep "AppDebug"
# iOS (from Xcode console or)
xcrun simctl spawn booted log stream --predicate 'eventMessage contains "AppDebug"' This told us that native initialization completed successfully, which meant the crash was happening during JavaScript bundle loading, after the native layer was up but before the first React component mounted.
Step 3: Add JavaScript-level logging. With the crash narrowed to the JS layer, we added console.log statements at the top of key files throughout the app:
// index.js
console.log('[Bundle Debug] index.js loaded');
// App.js
console.log('[Bundle Debug] App.js loaded');
// navigation/RootNavigator.js
console.log('[Bundle Debug] RootNavigator loaded');
// services/firebase.js
console.log('[Bundle Debug] Firebase service loaded'); We rebuilt the release binary with these logs in place and checked the device logs via adb logcat and Xcode console. The last log that appeared before the crash pointed us to the problematic area.
Step 4: Isolate the failing module. Once we knew the general area, we commented out top-level imports in the entry file and re-added them one at a time, rebuilding the release binary after each change. It's tedious, but when the app crashes before any debugging tool is available, this is the most reliable method. The failing import consistently broke the launch.
Step 5: Verify native build configuration. If you've isolated the issue to a specific native module, check the platform-specific build settings.
For Android, review android/app/build.gradle:
buildTypes {
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.release
}
} ProGuard can strip classes that native modules depend on. If a library provides ProGuard rules in its documentation, make sure they're included in proguard-rules.pro.
For iOS, verify that the release scheme in Xcode is configured correctly and that all required frameworks are linked for the release configuration.
What We Learned
Release mode crashes that happen before the app opens are the hardest to debug because every standard tool (LogBox, error boundaries, remote debugging) relies on the app being alive. The approach that works is: reproduce locally, start with native logs to identify the layer, move to JavaScript logs to trace the dependency chain, and narrow down module by module.
The biggest takeaway: test release builds on a real device after every significant change to native dependencies or configuration. Don't wait until you're ready to submit. The gap between "works in debug" and "opens in release" is where the most time-consuming bugs hide.
Step 5: CI/CD with GitHub Actions
Once your builds are stable, the next step is automating the entire pipeline. The workflow below triggers on version tags, runs tests, builds for both platforms in parallel, and submits to the respective stores.
Setup: Generate an Expo access token from expo.dev (Account Settings > Access Tokens) and add it as a GitHub repository secret named EXPO_TOKEN.
Create .github/workflows/build-and-deploy.yml:
name: Build and Submit
on:
push:
tags:
- 'v*'
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run lint
- run: npm test -- --coverage
build-and-submit:
needs: test
runs-on: ubuntu-latest
strategy:
matrix:
platform: [ios, android]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- uses: expo/expo-github-action@v8
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- run: npm ci
- run: eas build --platform ${{ matrix.platform }} --profile production --non-interactive
- run: eas submit --platform ${{ matrix.platform }} --latest --non-interactiveDeployment flow:
- Develop and merge features into
main. - When you're ready to release, create a version tag:
git tag v1.0.0 && git push --tags. - GitHub Actions runs tests, builds for both platforms in parallel, and submits to App Store and Google Play.
Step 6: Store Submission Checklist
Before your first submission, make sure the following requirements are in place.
iOS (App Store): Set up your App Store Connect listing with screenshots, description, and keywords. A privacy policy URL is mandatory. If your app uses analytics SDKs, you'll need to implement an App Tracking Transparency prompt. We recommend beta testing through TestFlight before production submission. If the app requires login, provide demo account credentials for the review team.
Android (Google Play): Complete the store listing with a feature graphic and screenshots. Fill out the content rating questionnaire and the data safety form. Ensure your target API level meets current Google Play requirements. Validate the build on the internal testing track before promoting to production.
Conclusion
Publishing a React Native app involves a series of decisions and configurations that go well beyond the application code. Choosing the right build toolchain (EAS Build for managed simplicity or React Native CLI for full native control) sets the foundation for your entire deployment pipeline. Automating builds and submissions through CI/CD takes the manual overhead out of the process and reduces deployment errors. And understanding how to systematically debug release mode crashes, especially the ones where the app installs but never opens, can save you days of frustration.
Start with a working pipeline, even a minimal one. You can always improve it as your project grows.




