reactnative #expo #mobile #frontend
If you've worked with a web app before, you know how convenient it is to run staging/development and production side by side. Mobile apps are a different story — by default you have to uninstall the production app before you can test a staging or preview build. Anyone who's shipped a React Native app knows this pain, especially during active development: you test against a dev API, QA needs a staging build, and production must point to a live server — all from the same codebase. Hardcoding URLs and switching them manually before each build is a recipe for disaster.
The good news is you can have the same workflow you're used to on the web. You can set up separate staging/development and production environments, and even install both the preview and production builds on the same device at the same time.
In this article, I'll walk you through how to set up separate environments — development, preview (staging), and production — in an Expo React Native project, so each environment gets its own:
- App name and icon
- Bundle ID/package name
- API URL, Socket URL, and third-party keys
- Firebase config file (
google-services.json/GoogleService-Info.plist) - EAS build profile and OTA update channel
By the end, you'll be able to run one command and get a build that's correctly wired for the environment you're targeting — no manual switching, and you (or your QA team) can install staging and production side by side on the same device.
NB: If you haven't read my previous articles, you can check them out here.
Prerequisites
- A React Native project using Expo (managed or bare workflow)
- An Expo account with EAS Build configured (create your first build)
- Basic familiarity with
app.json,app.config.js, andeas.json
Step 1: Understand the Problem
Without environment separation, you typically have one app.json with one API URL, one bundle ID, one Firebase config, and one set of environment variables for everything. That means:
- You can't install staging and prod builds on the same device (they share a bundle ID/package name).
- You can't run staging and production at the same time to compare behavior.
- You risk pointing a production build at a dev API by accident.
- QA can't test a staging build without you manually swapping values first.
The fix: drive everything from a single environment variable — APP_VARIANT — and let your config files branch on it.
Step 2: Define Your Build Profiles in eas.json
EAS Build uses profiles to define how each environment is built. Here's the eas.json from my project:
{
"cli": {
"version": ">= 16.19.3",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"channel": "development",
"env": {
"APP_VARIANT": "development"
}
},
"preview": {
"distribution": "internal",
"channel": "preview",
"env": {
"APP_VARIANT": "preview"
}
},
"production": {
"autoIncrement": true,
"channel": "production",
"env": {
"APP_VARIANT": "production"
}
}
},
"submit": {
"production": {}
}
}
A few things to notice:
- Each profile sets
APP_VARIANTvia theenvfield — this is the single source of truth that the rest of the app reads. -
developmentusesdevelopmentClient: trueso you get a dev client build (not a production JS bundle). -
previewandproductionuse internal distribution / store tracks respectively. - Each profile has its own OTA
channelso updates land on the right audience.
Step 3: Make app.config.ts Branch on APP_VARIANT
A static app.json can't read environment variables, so we use app.config.ts to dynamically set the app name, bundle ID, package name, and Firebase config file based on APP_VARIANT:
import { ExpoConfig, ConfigContext } from "expo/config";
export default ({ config }: ConfigContext): ExpoConfig => {
const variant = process.env.APP_VARIANT || "production";
// Define unique identifiers for each environment
const uniqueIdMap: Record<string, string> = {
development: "com.jocanola.bayttaalim.dev",
preview: "com.jocanola.bayttaalim.preview",
production: "com.jocanola.bayttaalim",
};
// Define unique names for each environment
const nameMap: Record<string, string> = {
development: "Bayt (Dev)",
preview: "Bayt (Preview)",
production: "Bayt",
};
const appIcon: Record<string, string> = {
development: "./src/assets/icons/apple-app-icon-dev.png",
preview: "./src/assets/icons/apple-app-icon-dev.png",
production: "./src/assets/icons/apple-touch-icon.png",
};
const androidGoogleServiceFile: Record<string, string> = {
development: "./google-services-dev.json",
preview: "./google-services-dev.json",
production: "./google-services.json",
};
const iosGoogleServiceFile: Record<string, string> = {
development: "./GoogleService-Info-dev.plist",
preview: "./GoogleService-Info-dev.plist",
production: "./GoogleService-Info.plist",
};
return {
...config,
name: nameMap[variant],
slug: "bayttaalim",
ios: {
...config.ios,
icon: appIcon[variant],
bundleIdentifier: uniqueIdMap[variant],
googleServicesFile: iosGoogleServiceFile[variant],
},
android: {
...config.android,
icon: appIcon[variant],
package: uniqueIdMap[variant],
googleServicesFile: androidGoogleServiceFile[variant],
},
};
};
Why this matters:
- Different bundle IDs mean development, preview, and prod builds can live side by side on the same phone.
-
Different app names (
Bayt (Dev),Bayt (Preview),Bayt) make it obvious which build you're opening. - Different Firebase config files keep analytics and push notifications isolated per environment.
Step 4: Keep app.json as the Static Base
Your app.json still holds the static defaults that app.config.ts spreads over — things that don't change between environments (permissions, splash screen, plugins, etc.). app.config.ts overrides the environment-specific fields at build time based on APP_VARIANT.
Step 5: Manage Secrets with EAS Environment Variables
Never commit API keys, socket URLs, or third-party tokens to git. Expo lets you store environment variables on the EAS server per environment, then pull them down locally with eas env:pull.
In my project, I keep a local .env.local (gitignored) that looks like this:
# Environment: preview
EXPO_PUBLIC_API_URL=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
EXPO_PUBLIC_SOCKETIO_URL=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
EXPO_PUBLIC_TAWK_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
The EXPO_PUBLIC_ prefix is what tells Expo to expose these variables to the client bundle at runtime.
To store these on EAS, run:
eas env:push --environment preview
Step 6: Validate Environment Variables at Runtime with Zod
Reading process.env directly across the app is fragile — a missing variable silently becomes undefined. I use Zod to validate all environment variables at app startup, so the app fails loudly if something is missing.
Here's env.ts:
import * as z from 'zod';
const createEnv = () => {
const EnvSchema = z.object({
API_URL: z.string(),
SOCKETIO_URL: z.string(),
APP_VARIANT: z.string(),
TAWK_KEY: z.string(),
});
const envVars = {
API_URL: process.env.EXPO_PUBLIC_API_URL,
SOCKETIO_URL: process.env.EXPO_PUBLIC_SOCKETIO_URL,
APP_VARIANT: process.env.EXPO_PUBLIC_APP_VARIANT,
TAWK_KEY: process.env.EXPO_PUBLIC_TAWK_KEY,
};
const parsedEnv = EnvSchema.safeParse(envVars);
if (!parsedEnv.success) {
throw new Error(
`Invalid env provided.
The following variables are missing or invalid:
${Object.entries(parsedEnv.error.flatten().fieldErrors)
.map(([k, v]) => `- ${k}: ${v}`)
.join('\n')}
`,
);
}
return parsedEnv.data ?? {};
};
export const env = createEnv();
Note: if you add extra keys to
EnvSchema(like an Agora app ID), remember to add a matchingEXPO_PUBLIC_*entry toenvVarsand to your.env.local/ EAS environment — otherwise Zod will throw at startup.
Now anywhere in the app, I import a single typed env object:
import axios from 'axios';
import { env } from '@/configs/env';
const api = axios.create({
baseURL: env.API_URL,
});
No more process.env.EXPO_PUBLIC_API_URL scattered across files — and a missing variable crashes in dev, not in production.
Step 7: Add Convenience Scripts to package.json
To avoid typing long EAS commands, I added scripts for each environment:
{
"scripts": {
"dev": "npx expo start",
"build:dev:android": "eas env:pull development --non-interactive && eas build --profile development --platform android --local",
"build:preview:android": "eas env:pull preview --non-interactive && eas build --profile preview --platform android --local",
"build:prod:android": "eas env:pull production --non-interactive && eas build --profile production --platform android --local",
"update:preview:android": "eas env:pull preview --non-interactive && eas update --channel preview --platform android --environment preview",
"update:prod:android": "eas env:pull production --non-interactive && eas update --channel production --platform android --environment production"
}
}
Each script:
- Pulls the latest env vars from EAS for that environment.
- Runs the build (or OTA update) against the matching profile and channel.
So to build a staging APK, I just run:
yarn build:preview:android
And to build a dev client APK:
yarn build:dev:android
The result: development and preview builds installed side by side on the same phone.
Step 8: Firebase Config per Environment
Push notifications and analytics need separate Firebase projects per environment. In my project root, I keep four files:
google-services.json # production Android
google-services-dev.json # dev + preview Android
GoogleService-Info.plist # production iOS
GoogleService-Info-dev.plist # dev + preview iOS
app.config.ts picks the right one based on APP_VARIANT (see Step 3). Only the production files are referenced as defaults in app.json.
Step 9: Putting It All Together
Here's the full flow when I want a staging build:
-
APP_VARIANT=previewis set by thepreviewEAS profile. -
app.config.tsreads it → the app becomesBayt (Preview)with bundle IDcom.jocanola.bayttaalim.preview. -
eas env:pull previewinjects theEXPO_PUBLIC_*variables into the build. -
env.tsvalidates them with Zod. - The app boots with the staging API URL, socket URL, and Firebase config.
- The build is published to the
previewOTA channel.
One command, one environment, zero manual switching.
Final Thoughts
Setting up multiple environments in Expo takes a bit of upfront work, but it pays off every single day. Once it's in place:
- You can install dev, staging, and prod builds on the same phone.
- You'll never accidentally ship a build pointing at the wrong API.
- QA gets reproducible staging builds.
- OTA updates stay scoped to the right audience.
- New team members can build for any environment with a single command.
The combination of eas.json profiles, a dynamic app.config.ts, EAS environment variables, and Zod validation gives you a setup that's both flexible and safe.
Thanks for reading till the end. Next time, I'll cover how to automate these builds with GitHub Actions so every push to staging or main produces the right build automatically. Stay tuned!



Top comments (0)