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
- One resolver decides the variant.
app.config.tstakes it fromresolveAppVariant(process.env)insrc/config/app-variant.ts: onlydevelopment,previeworproduction; an unknown name throws;APP_VARIANTandEXPO_PUBLIC_APP_VARIANTmust 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.envinto every localstart,exportandprebuild. - Every variant installs beside the others. Production keeps the store id; development and preview append
.devand.previewto 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. - 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 inassets/icons/production/, development and preview get badged copies), its own URL scheme, and the dev client's generatedexp+<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. - 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 setsdevelopmentClient; extra profiles extend a variant profile; a sharedbaseprofile the variants extend names no variant, environment ordevelopmentClienta 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.jsonenvreaches builds only;eas update --environment <name>reads the EAS environment and nothing else. EXPO_PUBLIC_*is public. Metro inlines each value into the JS bundle, where anyone with the app can read it, so noEXPO_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 intopublicEnvAllowwith 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.- App code reads the environment in one place, the way Metro inlines it. Only
src/config/readsprocess.env(except the literal comparison at an inclusionrequire(), rule 9), only asprocess.env.EXPO_PUBLIC_NAMEwith dot access; the variant comes fromcurrentAppVariant(), never fromConstants.expoConfig.extraor__DEV__. Server code is not app code: API routes (src/app/**/*+api.ts), server middleware and the helper folders named inserverDirsread their secrets fromprocess.envat request time, and app code never imports them. Why: brackets, destructuring and non-public names areundefinedin a release build while Jest still sees them, and a second source of truth drifts. - 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.jsonenvand 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. - Secret files are git-ignored.
.gitignorecovers.env*.local,*.p8,*.p12,*.jks,*.keystore,*.mobileprovision,credentials.jsonwhen a profile uses local credentials, and every key file eas.json points at. Why: onegit add .publishes a signing key, and keys cannot be unpublished. - Code is left out of a bundle only by a literal comparison at the
require()(this skill owns that rule;debug-menu-and-test-hooksowns the gate file,architecture-and-boundariesthe import side). Test-only code sits in one ternary withprocess.env.EXPO_PUBLIC_APP_VARIANT === 'production', never behind an imported constant, a helper call,__DEV__,NODE_ENVor anifblock; 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 intodevOnlyRequires. Why: verified on Expo SDK 57, Metro folds only literals in the same expression, so an importedIS_TEST_BUILDorSTORYBOOK_ENABLEDkept its module in the production bundle;__DEV__folds but also strips preview builds. - Metro's cache is keyed on the public values.
metro.config.jssetscacheVersionfrom everyEXPO_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 everyexpo exportand every release build on a machine withCIset; onlyeas update --environmentclears the cache by itself. - The exported bundle proves the strip. After a change to variants, gates, Metro or variables, export development and production back to back without
--clearand 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
- Decide the identities. Read 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.
- 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.tsand its test (the canonical resolver, shared byte for byte with the build, release and ads skills),templates/src/config/current-app-variant.tsand its test, andtemplates/src/env.d.ts. - Build
app.config.tsfromtemplates/app.config.tsand move every value of the oldapp.jsonor app config into its returned object:owner,version,updates.url,runtimeVersion,extra.eas.projectId(the EAS project link), every plugin with its options,ios.infoPlistusage strings,android.permissions, colours, splash. Only the id, name, scheme, icons and dev-client scheme depend on the variant; an oldextra.appVariantgoes (the app askscurrentAppVariant()). - Move the app's icon files into
assets/icons/production/:icon.png, its Icon Composer folder (expo.iconin the SDK 57 template; setSTORE_IOS_ICONto its name, or to'icon.png'when there is none), and the adaptive layers renamedandroid-foreground.png,android-background.png,android-monochrome.png. Thennode ${CLAUDE_SKILL_DIR}/scripts/make-variant-icons.mjs .writes badged copies toassets/icons/development/andassets/icons/preview/(amber band, violet hatched band), so every variant builds;icons-app-icon-and-splashmay redraw them at the same paths. - Metro: copy
templates/metro.config.jswithtemplates/scripts/metro/public-env-cache-version.tsandcompose-metro-config.ts(each with its test). Its Metro slot composes the optional kits ofscripts/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 aMetroKitthere (baseConfigfor one that builds on Expo's default, elsetransform). An app that keeps its ownmetro.config.jsaddsconfig.cacheVersion = publicEnvCacheVersion(process.env)on the object it finally exports. templates/eas.json: merge its threebuildprofiles into the app's eas.json (a sharedbasethey extend may stay). When there is no eas.json yet, leave it:eas-build-and-submitcreates it with these profiles, and until thencheck-variantsprints the expected eas.json SKIP.templates/dot-env.exampleto.env.example, and add!.env.exampleto.gitignorewhen.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.jsonthe same way anddiff dist/config-before.json dist/config-after.json: only icon paths, theexpo-dev-clientplugin and an oldextra.appVariantmay change. Then deleteapp.json.
- First save the production config:
- Route every read through
src/config/. Replace variant checks withcurrentAppVariant(); give each other public value a typed reader like examples/environment.ts and a line insrc/env.d.ts. Read references/environment-variables.md for inlining,.envloading, EAS environments and CI. - Place every secret. Read 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:setcommands (secret visibility for build-time secrets, sensitive for API-route secrets, plain text forAPP_VARIANTandEXPO_PUBLIC_APP_VARIANTin 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. - Gate test-only code with the literal comparison and export a sentinel from the test-only entry: references/stripping-test-code.md. The gate file and the debug registry belong to
debug-menu-and-test-hooks. - Run the app's gates:
npm run typecheck,npx eslint . --max-warnings 0,npx jest src/config scripts/metro --ci --maxWorkers=2. - Look at each variant:
APP_VARIANT=preview EXPO_PUBLIC_APP_VARIANT=preview npx expo config --type public(and development, production), thenAPP_VARIANT=preview EXPO_PUBLIC_APP_VARIANT=preview npx expo prebuild --no-install --cleanproves the icons and plugins resolve. Before a local build after switching variants, run that prebuild with the build's two variables:ios/andandroid/keep the previous variant until regenerated. - Check:
node ${CLAUDE_SKILL_DIR}/scripts/check-variants.mjs .andnode ${CLAUDE_SKILL_DIR}/scripts/check-env.mjs .. EachFAILline names the file, the rule and the fix; references/pitfalls-and-tests.md explains every rule. Fix and rerun until both printRESULT: PASS. - Prove the strip when variants, gates,
metro.config.jsor variables changed: export both variants back to back intodist/variant-proof/developmentanddist/variant-proof/production(commands in examples/variant-proof.md;dist/is already git-ignored), thennode ${CLAUDE_SKILL_DIR}/scripts/check-variant-bundle.mjs . --bundle dist/variant-proof/development --variant developmentand the same withdist/variant-proof/production --variant production. Deletedist/variant-proof/afterwards. - Report the three ids and names, the EAS variables the developer still has to create, the SKIP lines, and every
RESULTline.
Definition of done
-
src/config/app-variant.tsis the canonical file,app.config.tsresolves the variant through it, the production config diff shows only the intended keys, and there is noapp.json. -
npx expo config --type publicshows 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_VARIANTandenvironmentequal; the developer has theeas env:setlist for the EAS environments. - App code reads the variant only through
currentAppVariant()and other public values only throughsrc/config/(an inclusion flag's literal comparison at itsrequire()excepted); no secret (an LLM or paid API key included) has anEXPO_PUBLIC_name or sits in a committed file; every plugin the config names is installed. -
metro.config.jskeyscacheVersionon theEXPO_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 productionand the development run printRESULT: PASS. - Expected SKIP lines only:
SKIP eas.json [eas-profile] not yet created (eas-build-and-submit creates it)before EAS is set up, andSKIP src/test-only.ts [bundle-sentinel] not yet created (debug-menu-and-test-hooks creates it)while the app has no test-only gate. Anote:line about unpinned submit ids is a hand-off toeas-build-and-submit, not a failure. -
node ${CLAUDE_SKILL_DIR}/scripts/check-env.mjs .printsRESULT: PASS -
node ${CLAUDE_SKILL_DIR}/scripts/check-variants.mjs .printsRESULT: PASS
Anti-patterns
- Copying
templates/app.config.tsover 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_VARIANTonly in eas.json.eas update --environment productionnever 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 updatewithout--environmentin 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.envfiles and reuses a warm Metro cache. Always pass--environment <variant>.const isDev = process.env.APP_VARIANT === 'development'in app code.APP_VARIANTis not inlined: it isundefinedin every release build. CallcurrentAppVariant().__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.stagingorNODE_ENV=stagingto pick an environment.npx expo exportforcesNODE_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_KEYso 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 readsOPENAI_API_KEYfromprocess.env, deployed witheas deploy --environment production.- A standalone
eas submitwith 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, orSTORYBOOK_ENABLED ? require(...)with the flag read insrc/config/. Metro cannot fold an imported constant: the module ships (both verified on SDK 57). Write the literal comparison at the call.APP_VARIANT=productionin a committed.env. Every developer'sexpo startandprebuildthen builds the store identity; the variant belongs on the command line, in eas.json and in the EAS environments.npx expo export --clearto fix a stale variant. It hides the missing cache key until the next build; keycacheVersionon the public values.- Passing a token into
extraor 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 prebuildonly, 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 | 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 | 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 | 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 | 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 | 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 | A typed public-environment reader for the Tasks app (shape only) | Workflow step 3 |
| 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 officialeas-app-storesskill 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 atassets/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- theApiClientthat calls the app's API routes; Expo's officialeas-hostingskill 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 andeas 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.