AboutBlogContact
Mobile DevelopmentMay 19, 2025 8 min read 152Updated: May 18, 2026

React Native CI/CD in 2025: EAS Build, Fastlane, and OTA Updates That Actually Work

AunimedaAunimeda
📋 Table of Contents

React Native CI/CD in 2025: EAS Build, Fastlane, and OTA Updates That Actually Work

Building a React Native app manually is miserable. Xcode code signing, provisioning profiles, Android keystores, manual version bumps, uploading to TestFlight and Play Store Console - each step is fragile, rarely documented, and requires a Mac for iOS.

A proper CI/CD pipeline makes this: push code → automated tests → build for both platforms → ship to testers → ship to stores. Zero manual steps. The time investment to set it up pays back within a month.

Here's the complete setup.


The two-track release model

React Native gives you something native apps don't: the ability to update JavaScript code without going through the App Store.

Update type Method Time to users Store review
JS-only change (UI, logic, content) OTA update (EAS Update / CodePush) Minutes Not required
Native code change (new permissions, new native module) Store build (EAS Build) 1-3 days (review) Required

Use this distinction. Bug fixes and UI changes ship as OTA updates. Changes that touch native code go through the full build and review pipeline.


Option 1: EAS Build + EAS Update (Expo ecosystem)

EAS (Expo Application Services) is the cleanest end-to-end solution for React Native CI/CD in 2025. Works for both managed and bare workflow apps.

Setup

npm install -g eas-cli
eas login
eas build:configure  # Creates eas.json
// eas.json
{
  "cli": {
    "version": ">= 7.0.0"
  },
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal",
      "ios": { "simulator": true }
    },
    "preview": {
      "distribution": "internal",
      "channel": "preview",
      "android": { "buildType": "apk" },
      "ios": { "simulator": false }
    },
    "production": {
      "channel": "production",
      "autoIncrement": true,
      "android": { "buildType": "aab" }
    }
  },
  "submit": {
    "production": {
      "ios": {
        "appleId": "your@apple.com",
        "ascAppId": "1234567890",
        "appleTeamId": "TEAMIDHERE"
      },
      "android": {
        "serviceAccountKeyPath": "./google-service-account.json",
        "track": "internal"
      }
    }
  }
}

Code signing (the part everyone struggles with)

EAS handles code signing automatically. For iOS:

# First time: EAS creates provisioning profiles and certificates for you
eas credentials --platform ios

# EAS will:
# 1. Create App ID in Apple Developer portal
# 2. Create distribution certificate
# 3. Create provisioning profile
# 4. Store everything encrypted in EAS

For Android, generate your keystore once:

eas credentials --platform android
# EAS generates and stores your keystore securely
# CRITICAL: Download and backup your keystore - you can never update the app without it

Build commands

# Build for both platforms (runs in EAS cloud - no Mac needed for iOS)
eas build --platform all --profile production

# Build and submit to stores in one command
eas build --platform all --profile production --auto-submit

# Build preview for testers
eas build --platform all --profile preview
# Generates a QR code for installation - share with testers

OTA updates with EAS Update

npm install expo-updates

# Configure in app.json
{
  "expo": {
    "updates": {
      "url": "https://u.expo.dev/your-project-id"
    },
    "runtimeVersion": { "policy": "sdkVersion" }
  }
}
# Deploy JS update to preview channel
eas update --branch preview --message "Fix checkout button alignment"
# Users on preview builds get this update on next app open

# Deploy to production
eas update --branch production --message "Hotfix: payment error message"
# Users get this update within minutes, no App Store review

OTA updates apply when the app is reopened (background → foreground). The update is downloaded silently in the background. You can configure immediate restart for critical patches:

import * as Updates from 'expo-updates';

// Check and apply updates on app foreground
useEffect(() => {
  const checkForUpdates = async () => {
    if (!Updates.isEmbeddedLaunch) return; // Skip in development
    
    const update = await Updates.checkForUpdateAsync();
    if (update.isAvailable) {
      await Updates.fetchUpdateAsync();
      
      // For critical updates: restart immediately
      // For regular updates: prompt user or apply on next open
      if (update.manifest?.extra?.critical) {
        await Updates.reloadAsync();
      }
    }
  };
  
  const subscription = AppState.addEventListener('change', state => {
    if (state === 'active') checkForUpdates();
  });
  
  return () => subscription.remove();
}, []);

Option 2: Fastlane (non-Expo projects)

For bare React Native projects or existing apps not in the Expo ecosystem, Fastlane provides equivalent automation.

gem install fastlane
cd ios && fastlane init
cd android && fastlane init

iOS Fastfile

# ios/fastlane/Fastfile
platform :ios do
  
  desc "Build and upload to TestFlight"
  lane :beta do
    # Auto-increment build number from current TestFlight build
    increment_build_number(
      build_number: latest_testflight_build_number + 1
    )
    
    # Code signing via match (certificates stored in git repo or S3)
    match(
      type: "appstore",
      app_identifier: "com.yourcompany.yourapp",
      readonly: true
    )
    
    # Build
    build_app(
      workspace: "YourApp.xcworkspace",
      scheme: "YourApp",
      export_method: "app-store"
    )
    
    # Upload to TestFlight
    upload_to_testflight(
      skip_waiting_for_build_processing: true,
      changelog: last_git_commit[:message]
    )
    
    # Notify team
    slack(
      message: "iOS beta #{lane_context[SharedValues::BUILD_NUMBER]} uploaded to TestFlight",
      channel: "#releases"
    ) if ENV['SLACK_URL']
  end
  
  desc "Deploy to App Store"
  lane :release do
    match(type: "appstore", readonly: true)
    
    build_app(workspace: "YourApp.xcworkspace", scheme: "YourApp", export_method: "app-store")
    
    upload_to_app_store(
      submit_for_review: true,
      automatic_release: false, # Manual release after approval
      force: true
    )
  end
end

Android Fastfile

# android/fastlane/Fastfile
platform :android do
  
  lane :beta do
    # Increment version code
    increment_version_code(
      gradle_file_path: "app/build.gradle"
    )
    
    # Build signed AAB
    gradle(
      task: "bundle",
      build_type: "Release",
      project_dir: "./"
    )
    
    # Upload to Play Store internal track
    upload_to_play_store(
      track: "internal",
      aab: "app/build/outputs/bundle/release/app-release.aab",
      json_key: "../google-service-account.json"
    )
  end
end

Match: team code signing

Fastlane Match solves the "who has the certificate" problem by storing all certificates and provisioning profiles in an encrypted git repository:

# Initialize match (run once per team)
fastlane match init
# Creates a private git repo for certificates

# Generate certificates (run once per environment)
fastlane match appstore
fastlane match development

# Each developer/CI runner runs:
fastlane match appstore --readonly
# Downloads and installs certificates automatically

GitHub Actions pipeline

# .github/workflows/release.yml
name: Release

on:
  push:
    branches: [main]
    tags: ['v*']

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - run: npm ci
      - run: npm test -- --watchAll=false --passWithNoTests
      - run: npx tsc --noEmit

  ota-update:
    needs: test
    runs-on: ubuntu-latest
    # Only on main branch pushes (not tags)
    if: github.ref == 'refs/heads/main' && !startsWith(github.ref, 'refs/tags/')
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - run: npm ci
      - name: Deploy OTA update
        run: eas update --branch production --message "${{ github.event.head_commit.message }}" --non-interactive
        env:
          EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}

  build-and-submit:
    needs: test
    runs-on: ubuntu-latest
    # Only on version tags (v1.2.3)
    if: startsWith(github.ref, 'refs/tags/v')
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - run: npm ci
      
      - name: Build and submit to stores
        run: eas build --platform all --profile production --auto-submit --non-interactive
        env:
          EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}

Flow:

  • Every push to main → run tests → deploy OTA update to production
  • Every version tag (git tag v1.2.3 && git push --tags) → run tests → full store build and submission

Environment configuration

Never hardcode API URLs, keys, or environment-specific config in the app bundle:

// Using react-native-config
import Config from 'react-native-config';

const API_URL = Config.API_URL; // Set per environment in .env files

// .env.development
// API_URL=http://localhost:3000

// .env.staging  
// API_URL=https://staging-api.yourapp.com

// .env.production
// API_URL=https://api.yourapp.com

In EAS, store environment variables in project secrets:

eas secret:create --scope project --name API_URL --value "https://api.yourapp.com" --type string

Reference them in eas.json:

{
  "build": {
    "production": {
      "env": {
        "API_URL": "$API_URL",
        "SENTRY_DSN": "$SENTRY_DSN"
      }
    }
  }
}

Version strategy

# Semantic versioning: MAJOR.MINOR.PATCH
# MAJOR: breaking changes (rare for consumer apps)
# MINOR: new features
# PATCH: bug fixes

# What gets bumped:
# OTA update only → no version bump needed
# New build for stores → bump PATCH at minimum, update build number
# Major new feature → bump MINOR

# autoIncrement in eas.json handles build number automatically
# Version string is in package.json and synced to native via react-native-version or eas

Common CI/CD problems and fixes

iOS build fails: "No signing certificate found" → Run eas credentials --platform ios to initialize signing. If using Fastlane match: fastlane match appstore --readonly must run before build.

Android: "Keystore not found" → The keystore path in eas.json must be relative to the repo root, or use eas credentials to store it in EAS.

OTA update not applying to users → OTA updates only apply to builds on the same runtimeVersion. If you updated Expo SDK or changed native code, the runtime version changes and existing installs won't receive OTA updates - they need a new store build.

Build number not incrementingautoIncrement: true in eas.json only works when submitting. For Fastlane: use increment_build_number.

A working CI/CD pipeline changes how your team ships. Features and fixes go out in hours, not days. Testers always have the latest build via TestFlight/internal track. Production hotfixes ship as OTA updates without waiting for App Store review. The upfront investment - one or two days to configure - pays back continuously.


Aunimeda develops mobile applications for iOS and Android - from MVP to production-ready apps with full backend integration.

Contact us to discuss your mobile project. See also: Mobile App Development, Mobile Game Development

Read Also

Flutter vs React Native in 2026: An Engineer's Honest Comparisonaunimeda
Mobile Development

Flutter vs React Native in 2026: An Engineer's Honest Comparison

Flutter and React Native both ship to iOS and Android from a single codebase. But they make radically different bets. Here's the concrete trade-offs - performance benchmarks, ecosystem, hiring market, and which one fits which product.

React Native Push Notifications in 2026: Complete Guide (Expo + Firebase)aunimeda
Mobile Development

React Native Push Notifications in 2026: Complete Guide (Expo + Firebase)

Push notifications are the single highest-ROI feature in mobile apps. Open rates are 7x higher than email. Here's how to implement them correctly in React Native - including background handling, deep linking, and analytics.

Flutter vs React Native in 2026 - An Honest Comparisonaunimeda
Mobile Development

Flutter vs React Native in 2026 - An Honest Comparison

Flutter or React Native for your next mobile app? We've built production apps with both. Here's what actually matters when choosing in 2026.

Need IT development for your business?

We build websites, mobile apps and AI solutions. Free consultation.

Mobile App Development

Get Consultation All articles