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 ) Reading Here's Note: if you add extra keys to Now anywhere in the app, I import a single typed No more To avoid typing long EAS commands, I added scripts for each environment: Each script: So to build a staging APK, I just run: And to build a dev client APK: The result: development and preview builds installed side by side on the same phone. Push notifications and analytics need separate Firebase projects per environment. In my project root, I keep four files: Here's the full flow when I want a staging build: One command, one environment, zero manual switching. Setting up multiple environments in Expo takes a bit of upfront work, but it pays off every single day. Once it's in place: The combination of Thanks for reading till the end. Next time, I'll cover how to automate these builds with GitHub Actions so every push to
app.json, app.config.js, and eas.json
Step 6: Validate Environment Variables at Runtime with Zod
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.
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();
EnvSchema (like an Agora app ID), remember to add a matching EXPO_PUBLIC_* entry to envVars and to your .env.local / EAS environment — otherwise Zod will throw at startup.
env object:
import axios from 'axios';
import { env } from '@/configs/env';
const api = axios.create({
baseURL: env.API_URL,
});
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
{
"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"
}
}
yarn build:preview:android
yarn build:dev:android
Step 8: Firebase Config per Environment
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
APP_VARIANT=preview is set by the preview EAS profile.
app.config.ts reads it → the app becomes Bayt (Preview) with bundle ID com.jocanola.bayttaalim.preview.
eas env:pull preview injects the EXPO_PUBLIC_* variables into the build.
env.ts validates them with Zod.
preview OTA channel.
Final Thoughts
eas.json profiles, a dynamic app.config.ts, EAS environment variables, and Zod validation gives you a setup that's both flexible and safe.
staging or main produces the right build automatically. Stay tuned!

SOCIAL SHARE CARD GENERATOR