references/platform.md
Expo, EAS, and running many client apps
Environments
Three, named the same in every project: development, staging, production. Not dev/prod, not qa, not a fourth that only one client has.
App variants
All three install side by side on one device, which is the whole point. Drive it from a single env var in app.config.ts:
// app.config.ts
const variant = process.env.APP_VARIANT ?? 'development';
const ids = {
development: { suffix: '.dev', name: 'Acme (Dev)' },
staging: { suffix: '.staging', name: 'Acme (Staging)' },
production: { suffix: '', name: 'Acme' },
} as const;
export default ({ config }) => ({
...config,
name: ids[variant].name,
ios: { bundleIdentifier: `dev.chempa.acme${ids[variant].suffix}` },
android: { package: `dev.chempa.acme${ids[variant].suffix}` },
});
Keep static values in app.json and only the variant-dependent ones in app.config.ts. A config file that computes everything is harder to diff than one that computes three things.
Environment variables
- *
EXPO_PUBLIC_is inlined into the bundle at build time.** It is
configuration, and it is public. Treat it that way without exception.
- Secrets live on EAS, as environment variables scoped to an environment,
and are consumed by the build process, not by app code.
- Never commit a
.envwith real values. Commit.env.examplewith the
keys and blank values, so a new machine can be set up without a Slack message.
- Validate at boot with the zod schema in
src/config/env.ts. A build with
a missing API URL should fail on launch in staging, not fail silently in production.
eas.json
One build profile per environment, and profiles extend rather than repeat:
{
"build": {
"base": { "node": "22.x" },
"development": { "extends": "base", "developmentClient": true, "distribution": "internal",
"env": { "APP_VARIANT": "development" } },
"staging": { "extends": "base", "distribution": "internal",
"channel": "staging", "env": { "APP_VARIANT": "staging" } },
"production": { "extends": "base", "autoIncrement": true,
"channel": "production", "env": { "APP_VARIANT": "production" } }
}
}
Updates
EAS Update ships JavaScript, not native code. The distinction is the source of most incidents:
- Anything that changes native code needs a build. New native dependency,
changed permission, changed config plugin, SDK upgrade. Pushing an update that calls a native module the installed binary does not have is a crash for every user on that binary.
runtimeVersionis the safety mechanism. Use{"policy": "fingerprint"}
so Expo computes it from the native fingerprint and refuses to serve an incompatible update.
- Channels map to build profiles, branches map to git. Promote a tested
branch to the production channel rather than republishing.
- Never push an untested update to production to "fix it quickly". It
reaches everyone on next launch, and a bad one can brick launch for the whole install base. Staging first, always.
- SDK 55's Hermes bytecode diffing cuts update payloads to roughly a quarter,
which makes frequent small updates cheap. That is a reason to ship more often, not a reason to skip staging.
Upgrades
Expo SDKs move fast, and a studio running eight client apps on eight different SDKs has a maintenance problem it cannot staff.
- Upgrade every app once per SDK cycle, on a schedule, budgeted into the
retainer. An app three SDKs behind is a rewrite; an app one behind is an afternoon.
npx expo install --checkandnpx expo-doctorin CI, failing the build on
mismatched versions.
- Upgrade the internal starter first, hit the problems once, then roll the
learnings across clients.
- SDK 55 dropped the legacy architecture entirely, so any app still on it must
clear that hurdle before anything else.
Running many client apps
You have two real options. Pick per engagement, not per mood.
Option A: a template repo per client (default)
One repository per client, generated from an internal starter template.
Right when: clients are separate legal entities, code cannot be commingled, teams rotate between projects, or a client may take the codebase in-house.
The cost is drift. Manage it by extracting the genuinely shared parts into versioned private packages:
@studio/ui tokens, atoms, molecules, templates
@studio/lib api client, storage, formatters, error handling
@studio/config eslint, tsconfig, prettier, jest presets
Publish to a private registry, version with semver, and let each client app upgrade on its own cadence. This is where the tier boundary pays for itself: the line between ui/ and features/ is exactly the line between the package and the client repo. If organisms had been in ui/, none of it could be extracted.
Option B: a monorepo
apps/
acme-mobile/ Expo app
globex-mobile/ Expo app
admin-web/ Next.js
packages/
ui/ lib/ config/
pnpm workspaces plus Turborepo. Expo has first-class monorepo support and pnpm's isolated installs are the right default, since they stop an app importing a dependency it never declared.
Right when: the apps share a backend and a domain, one team works across all of them, and the client relationship is long-term.
Wrong when: clients are unrelated. A shared repo across unrelated clients is a confidentiality problem, a CI cost problem, and eventually a handover problem.
Watch for: Metro needs watchFolders and correct nodeModulesPaths, EAS builds need the workspace root as the build context, and native modules must resolve to a single copy. These are solved problems with well-documented fixes, but they are a day of setup, not an hour.
What every project gets regardless
- The same starter, the same tier structure, the same lint config
- The same three environments and the same variant scheme
- The same CI: typecheck, lint, unit and component tests, then build
- Sentry, analytics, and a crash-free-sessions number the client can see
- A
CLAUDE.mdand a README that state the stack and the tier rules, so the
next developer and the next agent both follow them
Consistency is the product here. A studio where every app is individually excellent but structurally unique cannot move people between projects, and that is the only thing that makes a studio more than a group of freelancers.