DEV Community

Cover image for Two Apps, One Device: Dynamic app.config.(js|ts) in Expo
john mbugua
john mbugua

Posted on

Two Apps, One Device: Dynamic app.config.(js|ts) in Expo

Table of contents

Getting started

Introduction

In my React Native + Expo workflow I run three environments: development, preview, and production. I use development for day to day coding and to take full advantage of Expo’s dev tools and fast iteration. Preview mimics production so I can see exactly how the app will behave before a release, no debug extras, realistic settings. Production is the real, ready build for users. With a dynamic app.config.(js|ts), I can keep all three installed side by side on one device.✅

What is app.config.(js|ts), app.json?

It's a file used for configuring Expo Prebuild, platform settings (names, bundle IDs, icons, deep-link schemes, OTA update channels, plugins, etc.)
It must be located at the root of your project.

What it can contain (most common properties)
The app config configures many things such as app name, icon, splash screen, deep linking scheme, API keys to use for some services and so on.

  • Identity: name, slug, owner, version
  • Platform IDs: ios.bundleIdentifier, android.package
  • Deep links: scheme, intentFilters (Android), associated domains (iOS)
  • Assets & UI: icon, splash, adaptiveIcon, userInterfaceStyle
  • Updates/OTA: updates.channel or runtimeVersion policy
  • Permissions & platform options: e.g., Bluetooth, camera, android.permissions
  • Build tooling: plugins, experiments, newArchEnabled
  • Runtime flags: extra (and EXPO_PUBLIC_* envs) for reading inside your app

Static vs dynamic

Static(json)

{
  "expo": { "name": "MyApp", "ios": { "bundleIdentifier": "com.company.myapp" } }
}

Enter fullscreen mode Exit fullscreen mode

Dynamic(json)

Dynamic configuration in Expo lets you generate your app settings at build time using real JavaScript/TypeScript. That means you can branch on environment variables, tweak names/IDs/icons/schemes per build, and expose safe runtime flags all from one file. Below are the key points; you can dive deeper in the official Expo Docs.

  • You can write your config in JavaScript (app.config.js) or TypeScript (app.config.ts) for more flexibility.
  • In these files you can use comments, variables, and single quotes—it’s just regular JS/TS.
  • ES module import isn’t supported in plain JS. Use require() instead. (Exception: TypeScript with tsx can use import.)
  • In dynamic configs, you can use modern JavaScript features like optional chaining (?.) and nullish coalescing (??). They work in app.config.ts via TS, and in app.config.js as long as your Node version supports ES2020 (Node 14+ recommended).
  • The config is re-evaluated on Metro reload, so changes show up when the bundler refreshes.
  • You can read env vars (e.g., process.env.APP_VARIANT) to pass environment info into your app (via extra or EXPO_PUBLIC_*).
  • The config must be synchronous—no Promises/async code inside.
// app.config.ts
const ENV = process.env.APP_VARIANT ?? 'production'; // env info

export default ({ config }) => {
  const isDev = ENV === 'development'; // variables + single quotes

  const name = isDev ? 'MyApp (Dev)' : 'MyApp';

  return {
    ...config,
    name,
    ios: { bundleIdentifier: isDev ? 'com.me.myapp.dev' : 'com.me.myapp' },
    android: { package: isDev ? 'com.me.myapp.dev' : 'com.me.myapp' },
    extra: { APP_VARIANT: ENV }, // expose to app at runtime
  };
};

Enter fullscreen mode Exit fullscreen mode

A quick story

For a long time I shipped with a static app.json. It worked until testing time. I needed the production build to feel what users feel, and the development build for my daily work. But with one static config (one bundle ID, one scheme), my phone couldn’t keep both installed. Every test cycle became: uninstall dev → install prod/preview → test → uninstall prod/preview → reinstall dev → keep coding. It was slow, tiresome, and I constantly second guessed whether I was testing the right thing. Thanks to dynamic configuration, I can now keep both environments on my phone and test each app side-by-side—no more uninstall/reinstall dance.

Why this matters

You want dev, preview, and prod to coexist on the same phone. That only works if each build has a unique identity. Dynamic app.config.js lets you generate those identities from one codebase .

How it works
Expo reads your app.config.js at build time. Because it’s JavaScript, you can:

  • Look at an env variable (here: process.env.APP_VARIANT)🕰️.
  • Branch the config to change names✳️
  • Return a plain object, Expo uses it to produce native projects with those values❇️.

Core idea: different IDs → different apps. iOS uses bundleIdentifier; Android uses package. If these differ, the OS treats them as different apps that can be installed side-by-side. Below is just a sample of my app.config.js

export default ({ config }) => ({
  ...config,
  name: getAppName(),
  slug: "tkt-app",
  owner: "iguru-ltd",
  version: "2.2.0",
  orientation: "portrait",
  icon: "./assets/images/icon.png",
  scheme: "tktapp",
  userInterfaceStyle: "automatic",
  newArchEnabled: true,
  ios: {
     // other configs for ios
    bundleIdentifier: getUniqueIdentifier(),
  },
  android: {
    // other config for android
    package: getUniqueIdentifier(),
    runtimeVersion: "1.0.0",
  },
  web: {
    bundler: "metro",
    output: "static",
    favicon: "./assets/images/favicon.png",
  },
  plugins: [
   // all the plugins configurations will go here/ some will appear here during installion
  ],
  experiments: {
    typedRoutes: true,
  },
  updates: {
     //configs for OTA update
    },
  extra: {
   // may include your project id for eas build

  },
});

Enter fullscreen mode Exit fullscreen mode

Export a function that returns your config

  • This makes the file dynamic, you can compute values first, then return them, Allowing you to add your own custom config.
export default ({ config }) => ({
  ...config,
  // ...all your fields
});

Enter fullscreen mode Exit fullscreen mode

Read the environment & set helpers

  • You can have different configuration in development, staging, and production environments. These flags drive everything that changes per build. To accomplish this, you can use app.config.js along with environment variables
const IS_DEV = process.env.APP_VARIANT === "development";
const IS_PREVIEW = process.env.APP_VARIANT === "preview";
Enter fullscreen mode Exit fullscreen mode

Change the app name per environment

  • Visible on-device so you don’t tap the wrong one. I love to have a helper function that return the App name based on the environment i am working on.
const getAppName = () => {
  if (IS_DEV) return "TKT APP (Dev)";
  if (IS_PREVIEW) return "TKT APP (Preview)";
  return "TKT APP"; // production
};

Enter fullscreen mode Exit fullscreen mode

Change the native identifiers (the key to parallel installs)

const getUniqueIdentifier = () => {
  if (IS_DEV) return "com.mbuguacode.tktapp.dev";
  if (IS_PREVIEW) return "com.mbuguacode.tktapp.preview";
  return "com.mbuguacode.tktapp"; // production
};

Enter fullscreen mode Exit fullscreen mode

All your Bluetooth permissions, splash config, and plugins remain the same, dynamic config only overrides what must differ.

A quick: how Expo turns this into separate apps

  • You run a build with APP_VARIANT=development (or preview/production).
  • app.config.js returns an object with unique name + unique bundleIdentifier/package.
  • EAS/CLI generates native projects with those identifiers.
  • The OS treats each identifier as a different app → they install side-by-side.

Conclusion

My take is: this isn’t about fancy tooling, it’s about mindset. Treating config as code has been the quiet upgrade that changed my day-to-day coding. I hope this article sparks something for you too maybe it’s setting up that second environment, maybe it’s finally badging your icons, or just tightening your release path.Kudos!🙌

Learn more on the official Expo Docs

Top comments (0)