Separate bundle identifiers for debug and release builds in Expo

2026

Separate bundle identifiers for debug and release builds in Expo

By default, debug and release Expo builds share the same app ID. Install a debug build and it replaces your production app. This is fine when you're heads-down in development, but annoying when you want to quickly test something without losing the production app — or when you want each build connected to a different Firebase project. The fix is to give debug builds a different bundle identifier — typically by appending .dev. On Android you can do this with applicationIdSuffix in Gradle, and on iOS you set PRODUCT_BUNDLE_IDENTIFIER per build configuration in Xcode. The problem is that expo prebuild regenerates those native files every time you run it, so any manual edits get wiped. That's exactly what config plugins are for.

What's a config plugin?

A config plugin is a JavaScript function that runs during expo prebuild and modifies the generated native project before compilation. It receives the Expo config, modifies native files through a set of typed modifiers, and returns the config. Expo ships modifiers for the most common targets — withAppBuildGradle, withXcodeProject, withInfoPlist, and so on.

function withMyPlugin(config, options) {
  config = withXcodeProject(config, (mod) => {
    // mod.modResults is a parsed representation of the .pbxproj file
    return mod;
  });
  return config;
}

withBuildTypeAppIds

I wrote a plugin called withBuildTypeAppIds that takes a production bundle ID and a suffix, then wires things up across all four of the relevant native files. On Android, withAppBuildGradle patches the debug block to add applicationIdSuffix:

buildTypes {
  debug {
    applicationIdSuffix ".dev"
  }
}

Gradle appends this automatically, so you never have to hardcode com.yourapp.dev anywhere. I also had to fix the deep link manifest with withAndroidManifest. Android resolves URL schemes at build time, and with the suffix in play the scheme would end up as com.yourapp.dev in release builds too if you're not careful. The plugin sets android:scheme in the intent filter explicitly to the production scheme. On iOS, withXcodeProject writes directly into the .pbxproj build configuration sections. Xcode has a "Debug" and a "Release" configuration and both have a PRODUCT_BUNDLE_IDENTIFIER setting — the plugin just sets each one:

if (cfg.name === 'Debug') cfg.buildSettings.PRODUCT_BUNDLE_IDENTIFIER = devId;
if (cfg.name === 'Release') cfg.buildSettings.PRODUCT_BUNDLE_IDENTIFIER = prodId;

Then withInfoPlist sets the URL scheme to $(PRODUCT_BUNDLE_IDENTIFIER) rather than a hardcoded string, so it automatically follows whichever bundle ID is active for that configuration. Wiring it up in app.config.ts looks like this:

import withBuildTypeAppIds from '../../packages/expo-config-plugins/src/withBuildTypeAppIds';
const PROD_BUNDLE_ID = 'com.yourapp';
export default ({ config }: ConfigContext): ExpoConfig => ({
  ...config,
  plugins: [
    [withBuildTypeAppIds, { prodId: PROD_BUNDLE_ID, devSuffix: '.dev' }],
  ],
  ios: { bundleIdentifier: PROD_BUNDLE_ID },
  android: { package: PROD_BUNDLE_ID },
});

After expo prebuild, debug builds install as com.yourapp.dev and release builds as com.yourapp. Both live on the same device without conflict, and you can point each at a different Firebase project too.

withAndroidReleaseSigning

While I had the config plugin pattern in mind I wrote a second one to handle Android release signing. Signing credentials shouldn't be committed to the repo, but they need to end up in build.gradle somehow. The convention is to store them in ~/.gradle/gradle.properties, which Gradle reads automatically and which is never committed. The plugin uses withAppBuildGradle to inject three things. First, variables that read from Gradle properties:

def uploadStoreFile = findProperty('YOURAPP_UPLOAD_STORE_FILE')
def uploadStorePassword = findProperty('YOURAPP_UPLOAD_STORE_PASSWORD')
def uploadKeyAlias = findProperty('YOURAPP_UPLOAD_KEY_ALIAS')
def uploadKeyPassword = findProperty('YOURAPP_UPLOAD_KEY_PASSWORD')
def hasUploadSigning = uploadStoreFile && uploadStorePassword && uploadKeyAlias && uploadKeyPassword

Then a signingConfigs.release block that uses them, and finally logic in the release buildType that applies the config when the properties are present — or throws a helpful Gradle error if you're attempting a release build without them. Each developer adds their keystore details to ~/.gradle/gradle.properties (never committed). CI injects the same values from a secrets manager. The plugin handles the plumbing, so there's nothing to remember or document beyond "put your keys in gradle.properties." One thing worth noting: expo prebuild can run multiple times, so any plugin that injects text into native files needs to be idempotent. I used marker comments (// @generated begin yourapp-release-signing) to track what was already injected, and checked for existing content before inserting anything.

Sharing across apps

Both plugins live in a shared packages/expo-config-plugins workspace package in our monorepo. Any app can import and use them with no duplication. Since they're just functions, testing them is straightforward too — pass in a mock config, check the output. The thing I like most about this approach is that there are no manually edited native files to maintain. Run expo prebuild, everything is set up correctly, and the configuration lives in version-controlled JavaScript rather than scattered across platform-specific files that you have to remember to keep in sync.

Enjoyed this?

I write about technology, coding, and systems.

Back to Writing

Comments (0)

Leave a comment

No comments yet. Be the first!