Wires APP_VARIANT builds: per-variant ids, names, icons, schemes, EXPO_PUBLIC_* as public, secrets in EAS/CI, test code out of store bundles. Use when adding a variant, env var, .env or secret. Not for signing (eas-build-and-submit).
Installs into .claude/skills of the current project.
Are you the author of App Variants And Environment?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/zakariaf-app-variants-and-environment)
---
name: app-variants-and-environment
description: "Wires APP_VARIANT builds: per-variant ids, names, icons, schemes, EXPO_PUBLIC_* as public, secrets in EAS/CI, test code out of store bundles. Use when adding a variant, env var, .env or secret. Not for signing (eas-build-and-submit)."
---
# App variants and environment
One Expo app builds as three variants, `development` (the dev client), `preview` (internal testing) and `production` (the stores), chosen by `APP_VARIANT`. Each variant installs beside the others and says what it is. Every `EXPO_PUBLIC_*` value is treated as public, secrets stay in EAS or CI, and test-only code never reaches a store bundle. Three checkers prove it: the config per variant, the source and files, and the exported bundle.
## Rules that must hold
1. **One resolver decides the variant.** `app.config.ts` takes it from `resolveAppVariant(process.env)` in `src/config/app-variant.ts`: only `development`, `preview` or `production`; an unknown name throws; `APP_VARIANT` and `EXPO_PUBLIC_APP_VARIANT` must be equal; both unset means development for local work and throws in a production bundle (`NODE_ENV=production`: `expo export`, `eas update`, `expo start --no-dev`) or on EAS Build (`EAS_BUILD=true`). The variant comes from the command, the eas.json profile or the EAS environment, never from a committed env file. Why: the config and the JS bundle are evaluated in different processes, so a typo or a lost variable otherwise ships the wrong app without an error, and Expo CLI loads a committed `.env` into every local `start`, `export` and `prebuild`.
2. **Every variant installs beside the others.** Production keeps the store id; development and preview append `.dev` and `.preview` to the iOS bundle identifier and the Android package, and every id is valid on both platforms. Why: a shared id overwrites the store app and its data on a tester's phone. Services keyed by app id (push, Sign in with Apple, IAP products, universal links, Firebase files) need each id registered or are tested on production-id builds, and ids built on the app id (App Groups, iCloud and keychain groups, extension bundle ids) derive from the variant's id.
3. **Every variant shows what it is.** Its own name (`Tasks (Dev)`, `Tasks (Preview)`, the store name for production), its own icon files on iOS and Android (which exist: production keeps the store icons in `assets/icons/production/`, development and preview get badged copies), its own URL scheme, and the dev client's generated `exp+<slug>` scheme only in development. Why: testers, screenshots and bug reports must name the build; a missing icon file stops prebuild and every EAS Build with ENOENT; with two installed apps on one scheme iOS opens either one (which is undefined) while Android asks the user to choose.
4. **Every EAS build profile names its variant twice and its environment once.** `env.APP_VARIANT` = `env.EXPO_PUBLIC_APP_VARIANT` = `environment` = the variant; only development sets `developmentClient`; extra profiles extend a variant profile; a shared `base` profile the variants extend names no variant, environment or `developmentClient` a child contradicts. The same two variables also exist as plain-text EAS environment variables in each environment, with equal values (EAS CLI warns that the profile's value wins). Why: eas.json `env` reaches builds only; `eas update --environment <name>` reads the EAS environment and nothing else.
5. **`EXPO_PUBLIC_*` is public.** Metro inlines each value into the JS bundle, where anyone with the app can read it, so no `EXPO_PUBLIC_` name or value is a secret: `SECRET`, `TOKEN`, `PASSWORD`, private keys, payment secret keys, and paid or LLM API keys (OpenAI, Anthropic, Gemini, ElevenLabs, Twilio, ...). The app calls a server that holds the key, such as an Expo Router API route. A key that is public by design (a Maps key restricted to the app id, a Stripe publishable key) goes into `publicEnvAllow` with a reason. Why: nothing that ships in the binary can be hidden, minified or bytecode alike, and a leaked LLM key bills the developer's account.
6. **App code reads the environment in one place, the way Metro inlines it.** Only `src/config/` reads `process.env` (except the literal comparison at an inclusion `require()`, rule 9), only as `process.env.EXPO_PUBLIC_NAME` with dot access; the variant comes from `currentAppVariant()`, never from `Constants.expoConfig.extra` or `__DEV__`. Server code is not app code: API routes (`src/app/**/*+api.ts`), server middleware and the helper folders named in `serverDirs` read their secrets from `process.env` at request time, and app code never imports them. Why: brackets, destructuring and non-public names are `undefined` in a release build while Jest still sees them, and a second source of truth drifts.
7. **Secrets live in EAS or CI, never in the repo or the app.** Build-time secrets (`SENTRY_AUTH_TOKEN`, store keys) are EAS variables with secret visibility or CI secrets; runtime secrets for API routes are EAS variables with sensitive visibility (EAS Hosting cannot deploy secret ones); eas.json `env` and committed env files hold none; no secret flows into the app config, whose public part (`extra`, plugin options) is embedded in every build. Why: committed and embedded values are readable for good; rotating the secret is the only cure.
8. **Secret files are git-ignored.** `.gitignore` covers `.env*.local`, `*.p8`, `*.p12`, `*.jks`, `*.keystore`, `*.mobileprovision`, `credentials.json` when a profile uses local credentials, and every key file eas.json points at. Why: one `git add .` publishes a signing key, and keys cannot be unpublished.
9. **Code is left out of a bundle only by a literal comparison at the `require()`** (this skill owns that rule; `debug-menu-and-test-hooks` owns the gate file, `architecture-and-boundaries` the import side). Test-only code sits in one ternary with `process.env.EXPO_PUBLIC_APP_VARIANT === 'production'`, never behind an imported constant, a helper call, `__DEV__`, `NODE_ENV` or an `if` block; any other inclusion flag works the same way (`process.env.EXPO_PUBLIC_STORYBOOK_ENABLED === 'true' ? require(...) : null`). Debug-bundle tooling (Reactotron, a devtools package) is not test-only code: its `__DEV__` gate is right, and other such modules go into `devOnlyRequires`. Why: verified on Expo SDK 57, Metro folds only literals in the same expression, so an imported `IS_TEST_BUILD` or `STORYBOOK_ENABLED` kept its module in the production bundle; `__DEV__` folds but also strips preview builds.
10. **Metro's cache is keyed on the public values.** `metro.config.js` sets `cacheVersion` from every `EXPO_PUBLIC_*` name and value, so it changes with any public value and differs per variant. Why: verified on Expo SDK 57, a production export made after a development export reused the development bundle byte for byte, test-only code included. It bites every `expo export` and every release build on a machine with `CI` set; only `eas update --environment` clears the cache by itself.
11. **The exported bundle proves the strip.** After a change to variants, gates, Metro or variables, export development and production back to back without `--clear` and check both: production holds no test-only sentinel and no secret; development holds every sentinel. Why: the gate, the cache key and the variables only work together, and only the artifact shows it.
## Workflow
1. **Decide the identities.** Read [references/variant-matrix.md](references/variant-matrix.md): the three variants, ids, names, icons, schemes, which services need each id registered, and the moments a variant enters the binary on iOS and Android.
2. **Merge the templates into the app** (line 1 names each repo path; fill `__APP_ID__`, `__APP_NAME__`, `__APP_SLUG__`, `__APP_SCHEME__`). Nothing the app already configures is lost:
- First save the production config: `mkdir -p dist && APP_VARIANT=production EXPO_PUBLIC_APP_VARIANT=production npx expo config --type public --json | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.stringify(JSON.parse(s),null,2)))' > dist/config-before.json` (`dist/` is git-ignored).
- Copy `templates/src/config/app-variant.ts` and its test (the canonical resolver, shared byte for byte with the build, release and ads skills), `templates/src/config/current-app-variant.ts` and its test, and `templates/src/env.d.ts`.
- Build `app.config.ts` from `templates/app.config.ts` and move every value of the old `app.json` or app config into its returned object: `owner`, `version`, `updates.url`, `runtimeVersion`, `extra.eas.projectId` (the EAS project link), every plugin with its options, `ios.infoPlist` usage strings, `android.permissions`, colours, splash. Only the id, name, scheme, icons and dev-client scheme depend on the variant; an old `extra.appVariant` goes (the app asks `currentAppVariant()`).
- Move the app's icon files into `assets/icons/production/`: `icon.png`, its Icon Composer folder (`expo.icon` in the SDK 57 template; set `STORE_IOS_ICON` to its name, or to `'icon.png'` when there is none), and the adaptive layers renamed `android-foreground.png`, `android-background.png`, `android-monochrome.png`. Then `node ${CLAUDE_SKILL_DIR}/scripts/make-variant-icons.mjs .` writes badged copies to `assets/icons/development/` and `assets/icons/preview/` (amber band, violet hatched band), so every variant builds; `icons-app-icon-and-splash` may redraw them at the same paths.
- Metro: copy `templates/metro.config.js` with `templates/scripts/metro/public-env-cache-version.ts` and `compose-metro-config.ts` (each with its test). Its Metro slot composes the optional kits of `scripts/metro/metro-kits.ts` (crash reporting's Sentry config is one; app-startup-and-bootstrap's slot contract), so an old file's wrappers (Sentry, NativeWind, SVG) move into a `MetroKit` there (`baseConfig` for one that builds on Expo's default, else `transform`). An app that keeps its own `metro.config.js` adds `config.cacheVersion = publicEnvCacheVersion(process.env)` on the object it finally exports.
- `templates/eas.json`: merge its three `build` profiles into the app's eas.json (a shared `base` they extend may stay). When there is no eas.json yet, leave it: `eas-build-and-submit` creates it with these profiles, and until then `check-variants` prints the expected eas.json SKIP.
- `templates/dot-env.example` to `.env.example`, and add `!.env.example` to `.gitignore` when `.env*` is ignored.
- `npx expo install expo-dev-client expo-splash-screen` (the config names both plugins; the development profile builds the dev client).
- Save `dist/config-after.json` the same way and `diff dist/config-before.json dist/config-after.json`: only icon paths, the `expo-dev-client` plugin and an old `extra.appVariant` may change. Then delete `app.json`.
3. **Route every read through `src/config/`.** Replace variant checks with `currentAppVariant()`; give each other public value a typed reader like [examples/environment.ts](examples/environment.ts) and a line in `src/env.d.ts`. Read [references/environment-variables.md](references/environment-variables.md) for inlining, `.env` loading, EAS environments and CI.
4. **Place every secret.** Read [references/secrets.md](references/secrets.md), list the build-time and runtime secrets, move any paid or LLM API call behind an API route, and hand the developer the `eas env:set` commands (secret visibility for build-time secrets, sensitive for API-route secrets, plain text for `APP_VARIANT` and `EXPO_PUBLIC_APP_VARIANT` in all three environments). They run in the developer's Expo account: stop and ask before creating, changing or deleting any EAS variable, and before rotating a leaked key.
5. **Gate test-only code** with the literal comparison and export a sentinel from the test-only entry: [references/stripping-test-code.md](references/stripping-test-code.md). The gate file and the debug registry belong to `debug-menu-and-test-hooks`.
6. **Run the app's gates:** `npm run typecheck`, `npx eslint . --max-warnings 0`, `npx jest src/config scripts/metro --ci --maxWorkers=2`.
7. **Look at each variant:** `APP_VARIANT=preview EXPO_PUBLIC_APP_VARIANT=preview npx expo config --type public` (and development, production), then `APP_VARIANT=preview EXPO_PUBLIC_APP_VARIANT=preview npx expo prebuild --no-install --clean` proves the icons and plugins resolve. Before a local build after switching variants, run that prebuild with the build's two variables: `ios/` and `android/` keep the previous variant until regenerated.
8. **Check:** `node ${CLAUDE_SKILL_DIR}/scripts/check-variants.mjs .` and `node ${CLAUDE_SKILL_DIR}/scripts/check-env.mjs .`. Each `FAIL` line names the file, the rule and the fix; [references/pitfalls-and-tests.md](references/pitfalls-and-tests.md) explains every rule. Fix and rerun until both print `RESULT: PASS`.
9. **Prove the strip** when variants, gates, `metro.config.js` or variables changed: export both variants back to back into `dist/variant-proof/development` and `dist/variant-proof/production` (commands in [examples/variant-proof.md](examples/variant-proof.md); `dist/` is already git-ignored), then `node ${CLAUDE_SKILL_DIR}/scripts/check-variant-bundle.mjs . --bundle dist/variant-proof/development --variant development` and the same with `dist/variant-proof/production --variant production`. Delete `dist/variant-proof/` afterwards.
10. **Report** the three ids and names, the EAS variables the developer still has to create, the SKIP lines, and every `RESULT` line.
## Definition of done
- [ ] `src/config/app-variant.ts` is the canonical file, `app.config.ts` resolves the variant through it, the production config diff shows only the intended keys, and there is no `app.json`.
- [ ] `npx expo config --type public` shows three distinct ids, names, icons and schemes; an unknown or mismatched variant fails; every icon file exists and a preview prebuild succeeds.
- [ ] When eas.json exists, it has development, preview and production profiles with `APP_VARIANT`, `EXPO_PUBLIC_APP_VARIANT` and `environment` equal; the developer has the `eas env:set` list for the EAS environments.
- [ ] App code reads the variant only through `currentAppVariant()` and other public values only through `src/config/` (an inclusion flag's literal comparison at its `require()` excepted); no secret (an LLM or paid API key included) has an `EXPO_PUBLIC_` name or sits in a committed file; every plugin the config names is installed.
- [ ] `metro.config.js` keys `cacheVersion` on the `EXPO_PUBLIC_*` values; the app's typecheck, lint and Jest pass.
- [ ] When strip-relevant code changed: `node ${CLAUDE_SKILL_DIR}/scripts/check-variant-bundle.mjs . --bundle dist/variant-proof/production --variant production` and the development run print `RESULT: PASS`.
- [ ] Expected SKIP lines only: `SKIP eas.json [eas-profile] not yet created (eas-build-and-submit creates it)` before EAS is set up, and `SKIP src/test-only.ts [bundle-sentinel] not yet created (debug-menu-and-test-hooks creates it)` while the app has no test-only gate. A `note:` line about unpinned submit ids is a hand-off to `eas-build-and-submit`, not a failure.
- [ ] `node ${CLAUDE_SKILL_DIR}/scripts/check-env.mjs .` prints `RESULT: PASS`
- [ ] `node ${CLAUDE_SKILL_DIR}/scripts/check-variants.mjs .` prints `RESULT: PASS`
## Anti-patterns
- **Copying `templates/app.config.ts` over the app's config.** The EAS project link, `updates.url`, `runtimeVersion`, plugin options, usage strings and permissions vanish without an error; merge, then diff the production config.
- **`APP_VARIANT` only in eas.json.** `eas update --environment production` never reads eas.json: with this resolver the update fails, and with one that defaults it ships as development, test hooks included. Create the two variables in every EAS environment as well.
- **`eas update` without `--environment` in CI.** EAS CLI asks for an environment only outside CI, and even then the answer only labels the update: the export gets no EAS variables, loads the runner's `.env` files and reuses a warm Metro cache. Always pass `--environment <variant>`.
- **`const isDev = process.env.APP_VARIANT === 'development'` in app code.** `APP_VARIANT` is not inlined: it is `undefined` in every release build. Call `currentAppVariant()`.
- **`__DEV__` as "the development variant".** `__DEV__` says whether Metro served a debug bundle; preview and production are release builds, and a development build can load a release bundle. Ask the variant.
- **`.env.staging` or `NODE_ENV=staging` to pick an environment.** `npx expo export` forces `NODE_ENV=production`; variants and EAS environments are the switch.
- **A secret behind `EXPO_PUBLIC_` "because the bundle is minified".** Hermes bytecode keeps string literals readable. Call a server, or keep the secret in a build step.
- **`EXPO_PUBLIC_OPENAI_API_KEY` so the app can call the model directly.** Anyone can lift the key from the bundle and spend the developer's quota. Put the call in an API route (`src/app/api/<name>+api.ts`) that reads `OPENAI_API_KEY` from `process.env`, deployed with `eas deploy --environment production`.
- **A standalone `eas submit` with nothing exported.** It evaluates the config without the build profile env, so the resolver gives the development id. Export the production pair for it, or pin the submit ids (`eas-build-and-submit`).
- **One bundle id for every variant to keep push or IAP simple.** Every install replaces the store app. Register the extra ids, or test those services on production-id TestFlight or Play internal builds.
- **`IS_TEST_BUILD ? require('./debug-menu') : null`, or `STORYBOOK_ENABLED ? require(...)` with the flag read in `src/config/`.** Metro cannot fold an imported constant: the module ships (both verified on SDK 57). Write the literal comparison at the call.
- **`APP_VARIANT=production` in a committed `.env`.** Every developer's `expo start` and `prebuild` then builds the store identity; the variant belongs on the command line, in eas.json and in the EAS environments.
- **`npx expo export --clear` to fix a stale variant.** It hides the missing cache key until the next build; key `cacheVersion` on the public values.
- **Passing a token into `extra` or plugin options to use it later.** The public config is embedded in the app; tools read secrets from their own environment.
- **Exporting the variables for `expo prebuild` only, then building in a fresh shell.** The Constants step and Metro run again during the native build and need the same two variables.
## Files in this skill
| File | What it is | Read/run when |
|---|---|---|
| [references/variant-matrix.md](references/variant-matrix.md) | The three variants, the resolver contract per command, ids, names, icons, schemes, services and entitlements keyed by id, EAS commands outside a build, the moments a variant enters the binary | Workflow step 1 |
| [references/environment-variables.md](references/environment-variables.md) | How `EXPO_PUBLIC_*` inlining works (verified on SDK 57), `.env` loading, the readers in `src/config/`, server code (API routes), EAS environments, CI | Workflow step 3 |
| [references/secrets.md](references/secrets.md) | Public versus secret (LLM and paid API keys), where each secret lives (API routes for runtime secrets), what ships in the binary, the canary check, `.gitignore`, leak response | Workflow step 4 |
| [references/stripping-test-code.md](references/stripping-test-code.md) | The inclusion gate, the Metro cache trap, the SDK 57 experiments, sentinels, the bundle proof | Workflow steps 5 and 9 |
| [references/pitfalls-and-tests.md](references/pitfalls-and-tests.md) | Symptoms with causes and fixes, test recipes, every checker rule, `rn-skills.config.json` keys | A check fails; writing tests |
| `templates/src/config/app-variant.ts` | The canonical resolver: `resolveAppVariant`, `parseAppVariant`, `variantIdentity`, `variantEnv` (synced from the library; do not edit here) | Workflow step 2 |
| `templates/src/config/app-variant.test.ts` | Its tests, including a fast-check property (synced; do not edit here) | With the resolver |
| `templates/src/config/current-app-variant.ts` | `currentAppVariant()`: the one runtime reader of the variant | Workflow step 2 |
| `templates/src/config/current-app-variant.test.ts` | Its tests | With the reader |
| `templates/src/env.d.ts` | The typed `EXPO_PUBLIC_*` declarations both TypeScript programs read (synced from the library; do not edit here) | Workflow step 2; each new public variable |
| `templates/app.config.ts` | The app config: identity, icons (`iconsFor`, `STORE_IOS_ICON`), scheme and dev-client scheme per variant; comments show where merged values go | Workflow step 2 |
| `templates/metro.config.js` | Metro config with `cacheVersion` keyed on the public values | Workflow step 2 |
| `templates/scripts/metro/public-env-cache-version.ts` | The tested cache-key helper `metro.config.js` requires | Workflow step 2 |
| `templates/scripts/metro/compose-metro-config.ts` | The Metro slot: `readMetroKits` (the optional `scripts/metro/metro-kits.ts`) and `composeMetroConfig` (Expo's default or one kit's base, then each kit's transform); `metro.config.js` requires it | Workflow step 2; a kit that needs Metro |
| `templates/scripts/metro/compose-metro-config.test.ts` | Its tests: no kits, one base and transforms in order, two bases refused, the kits file | With the helper |
| `templates/scripts/metro/public-env-cache-version.test.ts` | Its tests | With the helper |
| `templates/eas.json` | The three build profiles wired to their variant and EAS environment, merged into the app's eas.json (`eas-build-and-submit` creates the file) | Workflow step 2 |
| `templates/dot-env.example` | `.env.example`: every variable by name, no values | Workflow step 2 |
| [examples/environment.ts](examples/environment.ts) | A typed public-environment reader for the Tasks app (shape only) | Workflow step 3 |
| [examples/variant-proof.md](examples/variant-proof.md) | The back-to-back export proof with real output | Workflow step 9 |
| `scripts/make-variant-icons.mjs` | Writes badged development and preview icons from `assets/icons/production/` (checks the sources are square PNGs, and that the two themed icons differ in alpha) | Workflow step 2 |
| `scripts/check-variants.mjs` | Evaluates the config per variant and per broken environment (committed env files loaded): resolve, plugins installed, ids, names, icon files and icons, schemes, eas.json profiles (shared bases too), secrets in the config; loads metro.config.js to prove the cache key | Workflow step 8 |
| `scripts/check-env.mjs` | Scans app code (`src/`, a root `app/`, the entry and everything they import) and files: public-env secrets (LLM and paid API keys included, the app config too), the one reader (server code exempt), the variant reader, inclusion decided at the `require()`, server imports, secrets and variants in eas.json and env files, `.gitignore` | Workflow step 8 |
| `scripts/check-variant-bundle.mjs` | Checks an exported bundle for sentinels and secrets against its variant (secrets even before a test-only gate exists) | Workflow step 9 |
| `scripts/lib/env-scan.mjs` | Shared helpers: secret names and values, dotenv, `.gitignore` matching, eas.json profiles | Only when changing a checker |
| `scripts/lib/source-scan.mjs` | Dependency-free source lexer (synced from the library; do not edit here) | Only when changing a checker |
| `scripts/check-lib.mjs` | Shared checker library (synced from the library; do not edit here) | Never by hand |
| `scripts/selftest.mjs` | Runs every checker against the fixtures | After changing a checker or a template |
| `tests/bases/` | The base app and the planted decoys the fixtures assemble from | When adding a fixture |
| `tests/fixtures/` | Overlay cases per script: good, bad cases per rule, pass and error cases | When adding a rule |
| `assets/shared.json` | The shared files this skill syncs in | When adding a shared file |
| `assets/template-placeholders.json` | Values for the `__UPPER_SNAKE__` placeholders in template checks | When a template gains a placeholder |
## Related skills
- `expo-prebuild-and-config-plugins` - app.config.ts as the single source, config plugins, never editing native folders.
- `eas-build-and-submit` - the rest of eas.json: credentials, versions, submit profiles; Expo's official `eas-app-stores` skill is the EAS API reference.
- `debug-menu-and-test-hooks` - the test-only gate file, the debug registry and the sentinel this skill's bundle check reads.
- `icons-app-icon-and-splash` - may redraw the badged development and preview icons at `assets/icons/<variant>/` (icon.png 1024 x 1024, android-foreground/background/monochrome.png).
- `privacy-and-store-declarations` - the SDK inventory, store privacy answers and repository-wide secret audit.
- `networking-and-server-state` - the `ApiClient` that calls the app's API routes; Expo's official `eas-hosting` skill is the API-route and EAS Hosting reference.
- `crash-reporting-and-observability` - Sentry release names per variant and the upload token as an EAS secret.
- `ota-updates` - channels per variant and `eas update --environment`.
- `ci-pipeline-and-gates` - CI secrets and the variables every CI job exports.
- `ios-simulator-build`, `android-emulator-build` - building and proving each variant on a named device.