skip to content

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?

level: juniorimportance: must knowfreq 52%

answer

  1. JSON file vs code run by Node
  2. static read first, then passed in
  3. ({ config }) => ({ ...config })
  4. TS file wins over JS file
  5. no Promises, serialized to JSON

basics

~20 s

app.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 s

The 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 lines
typescript
import { 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

for a junior

Know that app.json is static JSON and app.config.ts is code that runs in Node and can read process.env.

for a middle

Explain the resolution order: static read first, passed as config to an exported function, TypeScript preferred over JavaScript, no Promises, serialized result.

for a senior

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.

for a principal

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