In an Expo car-sharing app, how would you use app.config.ts with an APP_VARIANT switch so dev, staging and production install side by side with their own bundle IDs and API URLs?
answer
- one config, many identities
- unique bundle ID per variant
- set the variable wherever config is evaluated
- native fields need a new build
- check with APP_VARIANT=… npx expo config
basics
~10 sBranch in app.config.ts on process.env.APP_VARIANT to set name, ios.bundleIdentifier, android.package and the API URL per variant. Set APP_VARIANT everywhere the config is evaluated: native builds, npx expo start and update exports.
solid answer
~50 sIn `app.config.ts`, read `process.env.APP_VARIANT` and derive a variant object: display name ("Car Share (Dev)"), `ios.bundleIdentifier` and `android.package` with a suffix such as `.dev` or `.staging`, and the API base URL in `extra` or an `EXPO_PUBLIC_` variable. A unique identifier per variant is what lets all three install side by side. The catch is that the config is evaluated wherever Expo tooling runs: the build profile must set `APP_VARIANT`, and so must the dev server script (`APP_VARIANT=development npx expo start`) and any update export. Otherwise the default branch, usually production, leaks into a dev manifest or a staging update. Identifiers and the app name are native, so they change only with a new native build. I validate the variant in the config and throw on an unknown value, and check each variant with `APP_VARIANT=staging npx expo config --type public`.
code
typescript · 25 linesimport { ExpoConfig, ConfigContext } from 'expo/config';
const VARIANTS = {
development: { suffix: '.dev', name: 'Car Share (Dev)', api: 'https://dev.api.example.com' },
staging: { suffix: '.staging', name: 'Car Share (Staging)', api: 'https://staging.api.example.com' },
production: { suffix: '', name: 'Car Share', api: 'https://api.example.com' },
} as const;
type Variant = keyof typeof VARIANTS;
const variant = process.env.APP_VARIANT as Variant | undefined;
if (!variant || !(variant in VARIANTS)) {
throw new Error(`APP_VARIANT must be one of ${Object.keys(VARIANTS).join(', ')}`);
}
const v = VARIANTS[variant];
const baseId = 'com.example.carshare';
export default ({ config }: ConfigContext): ExpoConfig => ({
...config,
name: v.name,
slug: 'car-share',
ios: { ...config.ios, bundleIdentifier: baseId + v.suffix },
android: { ...config.android, package: baseId + v.suffix },
extra: { ...config.extra, variant, apiUrl: v.api },
});go deeper
Know that side-by-side installs need a different bundle identifier and package name per variant, set in the app config.
Show the app.config.ts switch on APP_VARIANT and name every command where the variable must be set, including the dev server.
Design against silent fallbacks: one variant table, a throw on unknown values, public-config checks per variant, and identity changes shipped only as builds.
Weigh how many variants the team truly needs against the signing, store and service registrations each identifier multiplies.
## The goal A car-sharing team wants three installs on one phone: **Car Share (Dev)** talking to a local or dev backend, **Car Share (Staging)** for QA against staging, and **Car Share** from the store against production. On iOS and Android, two apps can coexist only if they have **different application identifiers**: `ios.bundleIdentifier` and `android.package` in the Expo app config. So the variants need different identifiers, names, icons if you like, and API URLs, all from one codebase. ## The switch in app.config.ts A dynamic config is evaluated by Expo CLI in Node, so it can read an environment variable and branch. `APP_VARIANT` is only a naming convention used in the Expo docs; nothing in Expo reads it except your config. ```ts const VARIANTS = { development: { suffix: '.dev', name: 'Car Share (Dev)', api: 'https://dev.api.example.com' }, staging: { suffix: '.staging', name: 'Car Share (Staging)', api: 'https://staging.api.example.com' }, production: { suffix: '', name: 'Car Share', api: 'https://api.example.com' }, } as const; ``` The config picks one entry and sets `name`, `ios.bundleIdentifier`, `android.package` and `extra.apiUrl` from it. Keep the table as the **single** place where variant identity lives. ## Where the variable must be set The config is evaluated in several places, and each needs the same variable: 1. **Native builds.** Each build profile sets `APP_VARIANT` (production too, once an unset variant throws), so the generated native project gets the right identifier and the embedded config the right `extra`. 2. **The dev server.** `npx expo start` evaluates the config to serve the manifest your development build loads. Run it through a script such as `"dev": "APP_VARIANT=development npx expo start"`; plain `npx expo start` serves the default branch. 3. **Update exports.** An update's manifest carries the config evaluated at export time, and `EXPO_PUBLIC_` values are inlined at that moment too. Exporting a staging update with production values points staging testers at production. 4. **Inspection.** `APP_VARIANT=staging npx expo config --type public` shows exactly what staging will carry. ## Traps - **Silent fallback to production.** Treat a missing or unknown variant as an error, not as production. Throwing in `app.config.ts` makes the CLI command fail loudly instead of producing a mislabelled build. - **Native fields are native.** `name`, `ios.bundleIdentifier` and `android.package` are baked into the binary by prebuild. Changing them in the config does nothing for installed apps until a new native build. - **Picking `.env` files with `NODE_ENV`.** `npx expo export` always forces `NODE_ENV` to `production`, so `NODE_ENV` does not reliably select a staging file. Rewrite `.env.local` with a script, or use the EAS environment for the job. - **Identifier-bound services.** Each new identifier is a new app to the stores and to services registered per identifier, so it needs its own signing credentials and service registrations. ## API URL: extra or EXPO_PUBLIC_ | Choice | How the app reads it | Good when | |---|---|---| | `extra.apiUrl` set in the variant table | `Constants.expoConfig?.extra?.apiUrl` | identity and URL must never disagree | | `EXPO_PUBLIC_API_URL` from the environment | inlined literal | the URL varies independently of the variant | Deriving the URL from the same table as the identifier makes "Staging app, production API" structurally impossible, which is usually what a team wants. ## Why a variant table beats scattered conditionals Teams often start with `IS_DEV ? a : b` sprinkled through the config. With three variants that becomes nested ternaries in several keys, and one missed branch gives a staging build the production name or icon. A table keyed by variant name turns every variant into one row, makes a missing field a type error in `app.config.ts`, and lets the same row feed `extra`, so the JavaScript side reads `extra.variant` instead of re-deriving it. Adding a fourth variant, say a demo build for partners, becomes a new row plus a new build profile rather than an edit in five places. ## A checklist before shipping a variant - `npx expo config --type public` run with each variant value shows the expected identifier and URL. - The dev script, the build profile and the update command all set the same variant. - Unknown variants throw during config evaluation. - A change to identifiers or the name ships as a new build, not an update.
- The dev build shows production data even though it was built with APP_VARIANT=development; what happened?In development the app reads the manifest served by `npx expo start`, which evaluates `app.config.ts` again on the developer's machine. If the server was started without `APP_VARIANT`, the default branch's `extra` wins. Start it through a script that sets the variable, or make an unset variant throw.
- Can an EAS Update rename the staging app or change its bundle identifier?No. The display name and identifiers are native values written into the binary when it is built; an update only replaces JavaScript and assets. Changing them needs a new native build, and a new identifier is effectively a new app for the stores and for signing.
- Why fail on an unknown variant instead of defaulting to production?A default hides mistakes: a forgotten variable yields a build or update that looks fine but talks to production or carries the production identifier. Throwing in the config makes the CLI command fail at once, which is cheaper than finding a mislabelled build in a tester's hands.
Like one set of franchise blueprints stamped with a different licence number for each branch: the stamp is applied when the building goes up, so a branch built without one opens under the head-office licence, and changing a licence later means construction work.
saying these in an interview costs you the question
- Setting APP_VARIANT in the build profile also sets it for npx expo start
- A new bundle identifier can ship to installed apps in an update
- Two variants can share one bundle ID and still install side by side
- NODE_ENV reliably selects the staging .env file for exports
- Unknown variants should quietly fall back to production