Multiple App Variants
Build the same source as separate installable apps - dev, staging and production with their own identifiers, names and icons.
export default ({ config }: ConfigContext): ExpoConfig => ({ ...config, name, ios, android })A phone identifies an installed app by its bundle identifier on iOS or its package name on Android. Two builds sharing one identifier are one app: installing the staging build replaces production, and it inherits production's storage, keychain entries and push token. App variants fix this by building the same source under different identifiers, so development, staging and production sit on the home screen as three separate apps with their own names and icons.
How it works
Rename app.json to app.config.ts. The config is then code that Expo CLI and EAS Build run in Node before the build, so it can read process.env and return different values. One variable - APP_VARIANT is the name the Expo documentation uses - chooses the identifier, the display name and the icon path. Each build profile in eas.json, the file describing how your builds are made, sets that variable in its env block.
Spreading the incoming config keeps everything you did not override. { ...config, name } and Object.assign({}, config, { name }) are the same thing.
What trips people
The variable must exist wherever the config is evaluated. A value in your own shell affects npx expo start only; EAS evaluates the config on its own machine, so the profile's env is what counts there. A new identifier is a new app to Apple and Google: separate credentials, a separate App ID, and one google-services.json per package name. Identifiers are also permanent once published, so choose the production one before the first release.
Example
// app.config.ts - run in Node by Expo CLI and by EAS Build, before bundling
import type { ConfigContext, ExpoConfig } from "expo/config";
const variant = process.env.APP_VARIANT ?? "production";
const identifiers: Record<string, string> = {
development: "com.example.orders.dev",
staging: "com.example.orders.staging",
production: "com.example.orders",
};
const names: Record<string, string> = {
development: "Orders (Dev)",
staging: "Orders (Staging)",
production: "Orders",
};
export default ({ config }: ConfigContext): ExpoConfig => ({
// Spread keeps anything still declared in app.json.
// Same as Object.assign({}, config, { ... }).
...config,
name: names[variant],
slug: "orders",
icon: `./assets/icon-${variant}.png`,
ios: {
...config.ios,
bundleIdentifier: identifiers[variant],
},
android: {
...config.android,
package: identifiers[variant],
},
// extra is readable at runtime through expo-constants
extra: { ...config.extra, variant },
});When to use it
- Keeping a tester's staging build installed next to the App Store copy of the same app.
- Giving each environment its own icon so a screenshot in a bug report is unambiguous.
- Pointing dev, staging and production builds at different API hosts and analytics keys.
- Separating push tokens so a staging notification cannot reach production users.
More examples
Build profiles that set the variable
eas.json is where each variant gets its value, and the only place a cloud build will find it. The channel field keeps over-the-air updates separated too, so a staging update can never land on a production install. Reach for this as soon as more than one person needs a build.
{
"cli": {
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"env": { "APP_VARIANT": "development" }
},
"staging": {
"distribution": "internal",
"channel": "staging",
"env": { "APP_VARIANT": "staging" }
},
"production": {
"autoIncrement": true,
"channel": "production",
"env": { "APP_VARIANT": "production" }
}
}
}Scripts so nobody has to remember the variable
The local start commands need the variable inline because nothing else sets it on your machine. The VAR=value prefix is shell syntax that Windows cmd does not understand, so a mixed team either runs these through a POSIX shell or uses a package such as cross-env.
{
"scripts": {
"start": "APP_VARIANT=development expo start",
"start:staging": "APP_VARIANT=staging expo start",
"build:staging": "eas build --profile staging --platform all",
"build:prod": "eas build --profile production --platform all"
}
}Showing which variant is running
A thin coloured strip across the top of non-production builds, reading DEVELOPMENT or STAGING, and nothing at all in the store build. Put it in your root layout once and testers stop reporting bugs against the wrong environment. The early return is safe because this component calls no hooks.
// src/components/VariantBanner.tsx
import Constants from "expo-constants";
import { StyleSheet, Text, View } from "react-native";
// Read once at module scope: the value is baked in at build time
const variant = Constants.expoConfig?.extra?.variant ?? "production";
export default function VariantBanner() {
if (variant === "production") {
return null; // the store build draws nothing
}
return (
<View style={styles.banner}>
<Text style={styles.label}>{String(variant).toUpperCase()}</Text>
</View>
);
}
const styles = StyleSheet.create({
banner: { paddingVertical: 4, backgroundColor: "#b45309" },
label: { color: "#ffffff", textAlign: "center", fontSize: 12, letterSpacing: 1 },
});
Discussion