In an Expo project, what is the difference between a static app.json and a dynamic app.config.ts, and when do you need the dynamic one?
answer
- JSON file vs code run by Node
- static read first, then passed in
- ({ config }) => ({ ...config })
- TS file wins over JS file
- no Promises, serialized to JSON
basics
~20 sapp.json is static JSON that CLI tools can also edit; app.config.ts is code Expo CLI evaluates in Node, so it can read environment variables and compute values. When both exist, app.json is read first and handed to the dynamic config.
solid answer
~40 sThe app config describes the app: name, slug, version, icons, `ios.bundleIdentifier`, `android.package`, `extra` and more. It drives `npx expo prebuild`, how Expo Go and dev builds load the project, and the update manifest. `app.json` is plain JSON, so CLI tools can write into it automatically. `app.config.ts` (or `.js`) is evaluated by Expo CLI in Node, so it can branch on `process.env`, share constants and compute values. If it exports a function, it receives the normalized static config as `({ config })` and returns the final object, like middleware. You need the dynamic form when a value must differ per environment or be computed; everything static can stay in `app.json`. The result must resolve synchronously and is serialized to JSON before any tool uses it; `npx expo config` prints it.
code
typescript · 8 linesimport { ExpoConfig, ConfigContext } from 'expo/config';
export default ({ config }: ConfigContext): ExpoConfig => ({
...config,
name: config.name ?? 'Car Share',
slug: 'car-share',
version: process.env.APP_VERSION ?? '1.0.0',
});go deeper
Know that app.json is static JSON and app.config.ts is code that runs in Node and can read process.env.
Explain the resolution order: static read first, passed as config to an exported function, TypeScript preferred over JavaScript, no Promises, serialized result.
Keep stable values static so tools can edit them, keep the dynamic layer thin, and verify the resolved output with npx expo config rather than reasoning about it.
Treat the app config as the source of generated native projects; its structure decides how easily variants, white labels and upgrades stay reproducible.
## What the app config is Every Expo project has an **app config** at its root, next to `package.json`. It is the single description of the app that Expo's tooling reads: - **Prebuild** (Continuous Native Generation) uses it to generate the `ios/` and `android/` projects: display name, `ios.bundleIdentifier`, `android.package`, icons, splash screen, URL scheme. - **Expo Go and development builds** read it through the manifest the dev server serves. - **EAS Update** embeds the public part of it in each update's manifest. - **Your JavaScript** can read the public part at runtime with `Constants.expoConfig` from `expo-constants`. It comes in two flavours: **static** (`app.json` or `app.config.json`) and **dynamic** (`app.config.js` or `app.config.ts`). ## Static config: app.json `app.json` is plain JSON. Its strengths are the strengths of data: - **Tools can edit it.** Static configs can be updated automatically by CLI tools; a dynamic config is code, so the developer must edit it by hand. - **It is obvious.** Anyone can read the whole config without running anything. - **The `expo` wrapper.** If the file has a top-level `expo: {}` object, that object is used in place of the root and other keys are ignored, which is why most `app.json` files nest everything under `expo`. Its weakness is the same: JSON cannot read an environment variable, compute a string, or branch. ## Dynamic config: app.config.js and app.config.ts A dynamic config is a module that **Expo CLI evaluates in Node** each time it needs the config, for example when `npx expo start` serves a manifest or when prebuild runs. That buys you: - comments, variables, helper functions and shared constants; - `process.env` access, so values can differ per environment (Expo CLI has already loaded the project's `.env` files into the process at that point); - TypeScript types via `ExpoConfig` and `ConfigContext` from `expo/config` when you use `app.config.ts`. It also has limits: it **cannot return a Promise** (no fetching remote settings during resolution), and its output must be plain data, because it is **serialized to a JSON manifest** before any tool uses it. `app.config.ts` works out of the box, but importing other TypeScript files from it needs the `tsx` require hook, which the Expo TypeScript guide describes. ## The resolution rules Expo CLI resolves the final config in a fixed order: 1. Read the **static** config: `app.config.json` if it exists, otherwise `app.json`. With neither, defaults are inferred from `package.json` and dependencies. 2. Read the **dynamic** config if `app.config.ts` or `app.config.js` exists. **If both exist, the TypeScript file is used.** 3. If the dynamic config **exports a function**, it is called with the static config as `({ config })`, like middleware; the usual pattern spreads `...config` and overrides a few keys. 4. The **return value** is the final config; it cannot contain Promises. 5. Everything is evaluated and **serialized** before tools consume it. 6. A top-level `expo` object in the result replaces the root. ## Choosing between them | Need | app.json only | app.json + app.config.ts | |---|---|---| | Fixed name, icon, version | enough | not needed | | CLI tools editing the config for you | yes | only the static half | | Different bundle ID or API URL per environment | no | yes | | Values read from `process.env` at config time | no | yes | | Typed config with autocomplete | schema only | `ExpoConfig` type | A common, tidy split is to keep stable values in `app.json` and let a small `app.config.ts` override only what varies, so tools can still edit the static part. ## Common mistakes - **Two dynamic files.** Adding `app.config.ts` next to an old `app.config.js` silently retires the JavaScript one, because the TypeScript file wins. - **Forgetting to spread.** A function that returns a fresh object instead of `{ ...config, ... }` drops everything `app.json` defined, from icons to `ios` settings. - **Shallow overrides of nested keys.** Returning `ios: { bundleIdentifier: '...' }` replaces the whole `ios` object; spread `...config.ios` first. - **Side effects.** The config runs every time a tool resolves it, so it must stay fast and deterministic: no network calls, no writing files. - **Expecting runtime evaluation.** The config is evaluated on the developer's or build machine, never on the phone; the app only sees the serialized public result. ## Seeing what you actually get Because the final config is computed, never guess it. `npx expo config` prints the resolved config Expo CLI will use, and `npx expo config --type public` prints the part that is embedded in builds and updates and visible through `Constants.expoConfig`. Also avoid importing `app.json` into app code: that bundles the raw file, not the resolved result a dynamic config produced.
- An Expo project has app.config.js and someone adds app.config.ts beside it; what changes?Expo CLI now evaluates only `app.config.ts`, because the TypeScript dynamic config takes precedence when both exist. Values set only in `app.config.js` silently disappear from the resolved config. Delete one of the two files and confirm the result with `npx expo config`.
- Why does keeping an app.json alongside app.config.ts still help?Static config can be updated automatically by CLI tools, while a dynamic config must be edited by hand. Keeping stable values in `app.json` and having `app.config.ts` spread `...config` and override only the varying keys keeps the tool-editable part editable and the code small.
saying these in an interview costs you the question
- A dynamic config cannot see what app.json already defines
- The dynamic config can await a fetch of remote settings
- When both JS and TS configs exist, they are merged
- Importing app.json in app code gives the resolved config
- The app config is read by the app at runtime straight from disk