---
title: Install dev and production side by side with app variants
authors: Jacob Clausen
published: July 21, 2026
categories: Development, React Native
tags: app variants
---

So, you just shipped your app to production. Congrats! Users are installing it, but soon your first bug report comes in. You open the production build on your phone, and yep, there it is. You draft a fix, install your development build, and dig in.

You can only have one version of your app installed on your phone at a time. Sounds reasonable, until you’re uninstalling your production app for the third time this week to debug something in your dev build, then reinstalling it, then uninstalling it again. There has to be a better way.

[App variants](https://docs.expo.dev/build-reference/variants/) fix this by giving each build its own identifier, so variants (_for example, dev, preview, and production_) can be installed side by side on one device, each treated as its own app.

For me this is a genuine dev experience upgrade, and it is the first thing I set up on real projects.

## What an app variant is made of

Think of every build as having two independent settings, an identity and an environment.

The first is _identity_, as in the bundle identifier on iOS or package name on Android, such as `com.myapp.app`. An identity is baked into the native build. It decides what represents a unique app and whether a new install replaces an old one. A device keeps only one app per identifier, so if development and production builds share one, installing either replaces the other. Giving each variant its own identifier lets them stay installed side by side.

Nothing you do at `expo start` time changes the identity. Because the native identity is baked into the native code of your app, this guide assumes custom builds you create on your machine or with [EAS Build](https://docs.expo.dev/build/introduction/). It does not apply to Expo Go, which runs every project under a single identity.

The second is _environment_. That means the set of variables that gets loaded when your [app config](https://docs.expo.dev/workflow/configuration/) is evaluated. EAS has three built-in environments, `development`, `preview`, and `production`, and the environment is where values like an API URL or an analytics key live. Environment decides how the app behaves once it is running.

## Step 1: config your app for different variants

Chances are your project has a static `app.json` today. A static file is great for stable values. Like we already covered, for variants to install next to each other they need identifiers of their own. But the identifier is set in your app config, and a static file can only hold one. So you also need a config that can set its values based on a variable. That is a [dynamic config](https://docs.expo.dev/workflow/configuration/#dynamic-configuration), and I reach for `app.config.ts` so the config is typed, though `app.config.js` is fine if you would rather stay in plain JavaScript.

### The two config files

When both files exist, Expo reads `app.json` first, hands it to your dynamic config as `{ config }`, and uses whatever the dynamic config returns. Think of `app.json` as the base layer and `app.config.ts` as a thin override on top. Your dynamic config has to export a function for this to work. If it exports a plain object while an `app.json` exists, the static file is ignored.

Your `app.json` holds the stable values and the defaults.

```json
{
  "expo": {
    "slug": "my-app",
    "owner": "your-org"
  }
}
```

And your `app.config.ts` holds the overrides. A simple example may look like this.

```typescript
import { ExpoConfig, ConfigContext } from "expo/config";

const appName = "MyApp";

export default ({ config }: ConfigContext): ExpoConfig => ({
  ...config,
  name: appName,
});
```

### Switching on `APP_VARIANT`

So, to create an app variant, we need to tell the config which one it is building. For that, we bring in `APP_VARIANT`, a plain environment variable your config reads. The name is only a convention, so you could call it anything. You can also create more, or different variants if you like. Switch on it to pick the identity.

```typescript
import { ExpoConfig, ConfigContext } from "expo/config";

const APP_ID_PREFIX = "com.myapp";

function getName(base: string) {
  switch (process.env.APP_VARIANT) {
    case "production":
      return base;
    case "preview":
      return `${base} (Preview)`;
    default:
      return `${base} (Dev)`;
  }
}

function getAppId() {
  switch (process.env.APP_VARIANT) {
    case "production":
      return APP_ID_PREFIX;
    case "preview":
      return `${APP_ID_PREFIX}.preview`;
    default:
      return `${APP_ID_PREFIX}.dev`;
  }
}

export default ({ config }: ConfigContext): ExpoConfig => ({
  ...config,
  name: getName(config.name ?? "MyApp"),
  ios: { ...config.ios, bundleIdentifier: getAppId() },
  android: { ...config.android, package: getAppId() },
});
```

Here we have put each variant on its own line with a `switch`, so the helpers stay readable and adding another variant is just another case. The shared part of the ID lives in `APP_ID_PREFIX`, so each case only sets the suffix and there is one place to change the base if it ever moves.

Note how we spread `...config`, which embeds the values from `app.json` into our dynamic config. If you forget to spread `config`, you drop everything in `app.json`. You can see it one level down as well, where setting `bundleIdentifier` without spreading `config.ios` first keeps the new ID but loses everything else under `ios`.

When in doubt, run `npx expo config` to read the resolved config Expo will use. Add `--json` for machine-readable output, and if you have `jq` available, `npx expo config --json | jq .name` pulls out a single field.

We pick `development`, `preview`, and `production` to match the default build profiles `eas build:configure` creates. Also, [EAS environment variables](https://docs.expo.dev/eas/environment-variables/) (where your variables will eventually live) ship with the same default environments. As a sidenote, while you can add as many build profiles as you like, [custom environments](https://docs.expo.dev/eas/environment-variables/manage/#custom-environments) beyond the built-in three are available on the Production and Enterprise plans.

Both of these concepts we will dig into further down the article.

### Why keep both files

You could ship with `app.config.ts` as the only config file, and plenty of apps do exactly that. The catch is that Expo tooling only writes into a static `app.json`. `eas build:configure` and `eas update:configure` fill values in for you when that file exists, and fall back to asking you to add them by hand when it does not. Some services go a step further and need a static `app.json`. [Expo Launch](https://launch.expo.dev/), for example, will not run without an `app.json` file, since it writes identity fields like `name` and `bundleIdentifier` into your config, which a dynamic config cannot accept.

### Defaulting to development

You might have noticed how I let a missing `APP_VARIANT` fall through to development in the switch. This is not a requirement, but I would rather have production identity only come from asking for it. The commands you run locally, `expo start`, `expo run`, and `expo prebuild`, do not make you pick a variant, so whatever the default is, that is what you get. Locally that is the dev build nearly every time, so it may as well be the default.

`APP_VARIANT` covers a fixed set of variants, so the switch is exhaustive by design. The default matters more once your config branches on the variant for values beyond the name and identifier. Say you add a `getBaseUrl()` (_or any other environment-specific value you might want here_) beside `getAppId()` and feed its result into your config so each variant has its own API URL. `APP_VARIANT` is only visible to your config, so the example goes through `extra` to get the value into application code.

```typescript
function getBaseUrl() {
  switch (process.env.APP_VARIANT) {
    case "production":
      return "https://example.com";
    case "preview":
      return "https://preview.example.com";
    default:
      return "https://dev.example.com";
  }
}

export default ({ config }: ConfigContext): ExpoConfig => ({
  ...config,
  // ... rest of your app.config.ts config here
  extra: {
    ...config.extra,
    apiUrl: getBaseUrl() // read back via Constants.expoConfig.extra.apiUrl
  },
});
```

With that in place, the default decides which backend you hit. Falling through to development keeps you on your dev backend. Falling through to production sends you to the real one. Depending on the app, that might fail loudly, like an auth backend turning away a dev access token, or it might quietly succeed and pollute the production environment with test data.

Later we can move `APP_VARIANT` to EAS environment variables and pull them down locally, at which point it is nearly always set and the default rarely comes up.

## Step 2: build each variant on EAS

Your config now reacts to `APP_VARIANT`, so [EAS Build](https://docs.expo.dev/build/introduction/) needs to set that variable for each build. The simplest way is an `env` block per profile in [`eas.json`](https://docs.expo.dev/eas/json/).

```json
{
  "build": {
    "development": {
      "developmentClient": true,
      "env": {
        "APP_VARIANT": "development"
      }
    },
    "preview": {
      "distribution": "internal",
      "env": {
        "APP_VARIANT": "preview"
      }
    },
    "production": {
      "env": {
        "APP_VARIANT": "production"
      },
      "autoIncrement": true
    }
  }
}
```

Run `eas build --profile development` and EAS sets `APP_VARIANT=development` before it evaluates your config. The dev identity kicks in and the dev build installs next to production without stepping on it. If anything, the `development` profile did not need the variable, since development is the default from Step 1 anyway. Production has to be set explicitly, and its build profile is where you do it.

### Where your variables live

Setting `APP_VARIANT` inside each `eas.json` profile is enough to get variants building, and it is a fine place to start. But that `env` block only applies to `eas build` (_for instance,_ `eas update` _cannot see the_ `env` _block in your_ `eas.json`). In fact, every other command that evaluates your config, including `expo start` and `expo run` on your own machine, never sees it and reads `APP_VARIANT` from your local shell instead, so for now you set it yourself, inline like `APP_VARIANT=development npx expo start`, in a `package.json` script, or in a `.env` file.

EAS can store those variables for you instead. With [EAS environment variables](https://docs.expo.dev/eas/environment-variables/), a variable lives on EAS under an environment rather than in `eas.json`. This is completely optional, but it is something I think makes the dev experience nicer, because you stop copying the same values between `eas.json` and your local `.env` file. You create a variable once per environment.

```console
$ eas env:create --name APP_VARIANT --value development --environment development --visibility plaintext
```

A build profile then names the environment it should load rather than spelling out each variable.

```json
{
  "build": {
    "development": {
      "developmentClient": true,
      "environment": "development"
    }
  }
}
```

And your own machine reaches the same values with `eas env:pull`. You name the environment and it writes those variables into a local `.env.local` file.

```console
$ eas env:pull --environment development
```

Now `expo start` and `expo run` read `APP_VARIANT` from `.env.local` without setting it inline (_or by manually changing values in .env-files_). Your builds read it from the same environment on EAS. There is one place to change a value instead of several.

### Building variants on your own machine

Building locally with `expo run` needs no extra setup once the variable is in your `.env.local`. The variant you prebuild is whatever `APP_VARIANT` resolves to, so set it (_or pull the environment_) first, then regenerate the native projects with `prebuild --clean` when you switch variants so the new identity is baked in.

```console
$ APP_VARIANT=<variant> npx expo prebuild --clean
$ npx expo run:[platform]
```

In case terms like `prebuild` or [CNG (Continuous Native Generation)](https://docs.expo.dev/workflow/continuous-native-generation/) are new to you; CNG generates your native projects on demand from your app config, package.json, and other input files instead of committing them to source control. EAS Build works the same way (_unless you commit your native projects_) and regenerates them from scratch for every build in the cloud. Whether you build locally or on EAS, switching variants never leaves anything stale behind.

The variant you build locally is almost always development. Preview and production are EAS Build’s job. If you do build one locally, use the release configuration so it embeds its JavaScript and config and runs without a dev server, the way a store install would.

```console
$ # iOS
$ npx expo run:ios --configuration Release
$ # Android
$ npx expo run:android --variant release
```

Then switch back with a development `prebuild --clean` before your next dev session, or the CLI keeps pointing its QR codes at whatever variant is on disk.

## How the environment reaches a running app

Once you run builds side by side, identity and environment can drift apart, so I want to be precise about when the environment reaches the app.

Identity is fixed at build time, as you saw earlier. When developing locally, keep in mind that, if you’ve generated a local native project with `npx expo prebuild` or `npx expo run:android|ios`, `expo start` takes its launch scheme from whatever native project is on disk, so `APP_VARIANT` does not affect which installed app opens.

The config your app reads at runtime through `expo-constants` is where it gets interesting, because where it comes from depends on the build:

- A plain build without `expo-dev-client` has its config compiled in. `Constants.expoConfig` is fixed at build time, and `APP_VARIANT` on `expo start` does nothing to it. Only a rebuild changes what the app reports.
- A development build (_one with_ `expo-dev-client`) downloads its config from the dev server every time it opens the project. Now `Constants.expoConfig` reflects the environment your server is running under, not the one you compiled with.

That second case is the one to watch. Start your server as `preview`, open your dev build, and it runs with the preview environment’s values without complaint. The dev menu even reads “MyApp (Preview)”, or whatever name you gave that variant, on a build whose identity is dev, and nothing errors. The fix is to match the server’s variant to the build you open, or once your variables live in an environment, run `eas env:pull --environment development` before `expo start`.

One more channel behaves differently. `EXPO_PUBLIC_` variables get inlined into your JS bundle at bundle time, so a build serving its JS from your dev server picks up your local environment variables, while a release build bakes them in. Anything without that prefix, `APP_VARIANT` included, never reaches your JS directly, so to read it at runtime you surface it through your config, usually the `extra` field shown just below (_or in this case, for example, you could name it_ `EXPO_PUBLIC_APP_VARIANT` _and read it directly_). The [environment variables guide](https://docs.expo.dev/eas/environment-variables/) covers the rest.

### Accessing extra values

[Accessing extra values](https://player.mediadelivery.net/embed/615344/150ae5ff-ba84-452f-b59c-d696ddbd5a41)

To see how a development build gets its config from the server, add the variant to the `extra` field of your config. Then show these values on screen: the variant from `expo-constants`, `process.env.APP_VARIANT`, and `process.env.EXPO_PUBLIC_APP_VARIANT`.

```typescript
const config: ExpoConfig = {
  name: getName(),
  slug: "my-app",
  extra: {
    variant: process.env.APP_VARIANT ?? "unset",
  },
  // the rest of your app config....
};
```

```tsx
import Constants from "expo-constants";

<Text>variant: {Constants.expoConfig?.extra?.variant}</Text>
<Text>APP_VARIANT: {String(process.env.APP_VARIANT)}</Text>
<Text>EXPO_PUBLIC_APP_VARIANT: {String(process.env.EXPO_PUBLIC_APP_VARIANT)}</Text>
```

`APP_VARIANT` renders as `undefined` because it lacks the `EXPO_PUBLIC_` prefix, while `EXPO_PUBLIC_APP_VARIANT` shows its value.

Restart the server with a different `APP_VARIANT` (_and_ `EXPO_PUBLIC_APP_VARIANT` _to match_) and open the project again, from the QR code on a device or with `i` in the simulator (_a plain reload may not pick up the change_). A dev build shows the new value. A preview or production install keeps showing the variant it was compiled as.

## Making the QR code open your dev build

With more than one variant installed, the QR code from Expo CLI can open the wrong app. By default the [`expo-dev-client`](https://docs.expo.dev/develop/development-builds/introduction/) config plugin adds a generated scheme named `exp+<slug>` to your native project, built from your app’s slug. The slug is the same across variants, so every variant registers the same scheme and answers the same link. Which one the OS opens is not something you can rely on. For example, it may open the preview build every time, even with the dev build installed last.

The fix is to make the dev build the only app that answers the generated scheme.

```tsx
plugins: [
  [
    "expo-dev-client",
    {
      addGeneratedScheme: process.env.APP_VARIANT === "development",
    },
  ],
],
```

After a clean prebuild and rebuild, only the development build registers `exp+<slug>` and the QR code opens it. Variants built before the change still answer the link, though, so rebuild them too, or take them off the device.

There is one caveat if you build other variants locally with `expo run`. The CLI takes the link’s scheme from whatever native project you last prebuilt, so building another variant locally can leave your next `expo start` pointing at the wrong app. Before your next dev session, regenerate the native projects for development.

```console
$ APP_VARIANT=development npx expo prebuild --clean
```

If your variables live on EAS, run `eas env:pull --environment development` first and skip the inline variable. If you build every other variant remotely and keep your local native project configured for development, this caveat does not come up.

`addGeneratedScheme` only touches the generated scheme, not a custom `scheme` in your app config, and the custom scheme is something I rarely vary anyway. Varying it helps when an external link has to pick between apps built from the same project, as with white-label apps, where each brand answers its own links through a scheme like `brandone://` or `brandtwo://`.

## Registering each variant with your services

One chore remains once each variant is its own app, and it comes with the native identity itself. `APP_VARIANT` can make your config return a different bundle identifier or package name, but it cannot create the external registrations those identifiers need.

Some services and SDKs recognize your app by that identifier. A build may need an ID you registered ahead of time, so each variant needs its own setup, either as a separate app or as another allowed identifier under the same project. The [variants guide](https://docs.expo.dev/build-reference/variants/) gives Google Maps and Firebase Cloud Messaging as examples. A good sign is that a service asks for your package name or bundle identifier during setup.

Registering each variant separately is more work. For anything that records or sends production data, like analytics, crashes, or push, it is usually worth it so dev builds do not pollute production.

## Giving each variant its own icon

When you have two or three installed side by side, a different icon is the fastest way to avoid tapping the wrong one, and teammates have always appreciated it. With a dynamic config it is one more helper, and the variant images can be as simple as the same icon with a different background color. Mine returns `undefined` for production, so the icons in `app.json` flow through untouched, while dev and preview override them with a single flat image.

```typescript
function getIcon() {
  switch (process.env.APP_VARIANT) {
    case "production":
      return undefined; // production keeps the icons from app.json
    case "preview":
      return "./assets/images/icon-preview.png";
    default:
      return "./assets/images/icon-dev.png";
  }
}

const icon = getIcon();

export default ({ config }: ConfigContext): ExpoConfig => ({
  ...config,
  icon: icon ?? config.icon,
  ios: {
    ...config.ios,
    icon: icon ?? config.ios?.icon,
  },
  android: {
    ...config.android,
    icon: icon ?? config.android?.icon,
    adaptiveIcon: {
      ...config.android?.adaptiveIcon,
      foregroundImage: icon ?? config.android?.adaptiveIcon?.foregroundImage
    },
  },
  // ... rest of your config
});
```

`ios.icon` and `android.adaptiveIcon` take precedence over the top-level `icon`, which is why the example overrides them as well.

## Keeping variants aligned in updates

Everything so far keeps a variant aligned at build time. [EAS Update](https://docs.expo.dev/eas-update/introduction/) is one more place to keep it aligned, after the app has shipped. The channel is how an update reaches a variant, and the environment you publish with, set with `--environment`, decides which values it carries.

That channel comes from the build profile, set right next to the environment it already assigns.

```json
{
  "build": {
    "preview": {
      "environment": "preview",
      "channel": "preview"
    }
  }
}
```

With the names kept consistent, the whole chain matches. Your `preview` variant is built by the `preview` profile, loads the `preview` environment, and receives updates on the `preview` channel, so an update published there lands exactly where you expect.

```console
$ eas update --channel preview --environment preview
```

However, point that at the wrong environment and your preview variant will happily accept an update carrying the development environment’s values, with no complaint. It is the same principle as at build time, keep a variant and its environment matched, but a mistake here reaches people who already have the app. The channel routes the update, the environment decides how it behaves, and it is on you to keep them aligned.

## Wrap-up

I know this may look like a lot, but getting started is genuinely straightforward. You add a dynamic config that reads one variable, a build profile per environment, and channels that match, and every build you care about lives on one phone, each honest about which it is. It rarely needs updating, so much so that I now copy the same config into new projects with only a few changes. You feel the benefit every time you switch between builds.

So when a production bug lands while you are mid-feature, you tap over to production, reproduce it, and tap back to where you were, with nothing uninstalled and nothing lost. If you have spent years overwriting one build to check another, this ends that for good.

App variants are an important piece of a dev experience you all deserve.