Expo Config Plugins Guide 2026: Writing Custom Mods, withXcodeProject, and withAndroidManifest

Config plugins keep native iOS and Android changes in code with Expo's Continuous Native Generation. A working 2026 guide to withXcodeProject, withAndroidManifest, prop validation, idempotency, and shipping plugins as npm packages.

Expo Config Plugins Guide 2026

Updated: July 9, 2026

Expo config plugins are typed JavaScript functions that modify iOS and Android native project files during npx expo prebuild, so you can express every native change (permissions, entitlements, build phases, Gradle properties, Xcode targets) as versioned code instead of hand-edited Info.plist or AndroidManifest.xml files. In 2026, with Continuous Native Generation now the default workflow for most React Native fintech and enterprise apps, config plugins are the single hinge between your app config and the native binary. I've been writing and shipping them across a fintech monorepo for two years, and this is the walkthrough I wish somebody had handed me on day one.

  • Config plugins are synchronous functions that append mods (async modifiers evaluated only during expo prebuild) to your Expo config.
  • The three mods you'll use 90% of the time are withInfoPlist, withAndroidManifest, and withXcodeProject, all exported from expo/config-plugins.
  • Continuous Native Generation (CNG) treats ios/ and android/ as ephemeral artifacts, which means plugins must be idempotent or a re-run of prebuild will corrupt your project.
  • Prefer typed ConfigPlugin<Props> signatures and the AndroidConfig/IOSConfig helper namespaces over hand-rolled XML edits. They survive Expo SDK upgrades.
  • Dangerous mods (regex source-file rewrites) are a last resort. Wrap them in tests, log every change, and fail loudly on unexpected input.
  • Ship reusable plugins as an app.plugin.js entry from an npm package so consumers get zero-config integration.

What are Expo config plugins?

An Expo config plugin is a synchronous JavaScript function that accepts your ExpoConfig object, appends one or more mods to it, and returns the mutated config. Mods are asynchronous callbacks that Expo executes during the syncing phase of npx expo prebuild. Each mod receives a strongly typed handle to a specific native file (Info.plist as a plain object, AndroidManifest.xml as an xml2js tree, the Xcode project as an xcode.project instance), edits it, and returns the mutated config. That two-phase design is deliberate: plugin functions themselves must be serializable and fast because they run every time getConfig is called (dozens of times per bundling session), while mods run only when native files are being written.

Concretely, plugins let you do things like "add a background modes entry to Info.plist," "inject a Kotlin snippet into MainApplication," or "add a PBXBuildFile reference so an .xcframework gets linked" without ever opening Xcode. In a fintech context that matters. The diff between two builds is code, code lives in git, code is reviewable, and the person writing the compliance sign-off can see exactly what changed in the native shell. If you're still weighing whether to adopt this pattern or hand-write native modules, my earlier piece on building native modules with Turbo, Expo, and Nitro compares the three surfaces side by side.

Continuous Native Generation and why plugins exist

Config plugins are the extension mechanism for Continuous Native Generation (CNG), the workflow where ios/ and android/ are treated as build artifacts rather than source-controlled directories. In a CNG project you delete both folders, run npx expo prebuild, and Expo regenerates them from your app.json, your installed packages (via autolinking), and any config plugins you have registered. Honestly, the upgrade story is the reason we adopted CNG at my job: bumping React Native or Expo SDK becomes npm install plus npx expo prebuild --clean instead of the multi-day port-your-native-project ritual that used to eat entire sprints.

The catch is that the moment you need any native customization Expo doesn't ship out of the box (a specific Gradle property, a custom URL scheme, a native SDK requiring an Info.plist permission string), you have exactly two options. Either you eject to a "bare" workflow and abandon CNG's upgrade ergonomics, or you write a config plugin. The upgrade math almost always favors the plugin. For teams still deciding between config plugins and OTA-first workflows, my write-up on EAS Update rollouts and CI/CD explains how the two fit together.

Anatomy of a config plugin

Every config plugin exports a function with the signature ConfigPlugin<Props>. The generic Props parameter is what users pass in the plugins array of app.json. Here's a minimal typed skeleton I use as a starter template on every new plugin:

// plugins/withMyPlugin.ts
import { ConfigPlugin, createRunOncePlugin } from 'expo/config-plugins';

type Props = {
  apiKey: string;
  enableAnalytics?: boolean;
};

const withMyPlugin: ConfigPlugin<Props> = (config, props) => {
  // 1. Validate props up front. Fail fast, not during native builds.
  if (!props?.apiKey) {
    throw new Error('[withMyPlugin] "apiKey" is required.');
  }

  // 2. Chain mods. Each mod returns a new config.
  // config = withInfoPlist(config, ...);
  // config = withAndroidManifest(config, ...);

  return config;
};

// createRunOncePlugin guards against double-application when the plugin
// is listed twice (e.g., transitively via another plugin).
const pkg = { name: 'my-plugin', version: '1.0.0' };
export default createRunOncePlugin(withMyPlugin, pkg.name, pkg.version);

Two details matter here. First, createRunOncePlugin is the pattern I now consider non-negotiable: it uses the plugin name as a de-duplication key and stores it in _internal.pluginHistory, so if two libraries both apply your plugin transitively you won't get double Info.plist entries. Second, prop validation belongs at the top of the plugin function (not inside a mod), because plugin functions run during getConfig, and you want type errors surfacing during dev-server startup, not halfway through a 90-second prebuild.

Registering the plugin in app.json

Once the plugin file exists, register it in the plugins array with a relative path (for local plugins) or a package name (for published ones):

{
  "expo": {
    "name": "MyApp",
    "plugins": [
      ["./plugins/withMyPlugin", { "apiKey": "sk_live_xxx", "enableAnalytics": true }]
    ]
  }
}

Writing a withAndroidManifest mod

withAndroidManifest exposes the parsed AndroidManifest.xml as config.modResults, an xml2js-shaped object. The naive approach is to reach into modResults.manifest.application[0] and mutate arrays by hand. That works, but the moment Expo tweaks how it emits the manifest (they did in SDK 52 for edge-to-edge, and again in SDK 54) your plugin breaks. The safer path is to use the AndroidConfig.Manifest helpers, which are stable across SDK versions.

Here's a real example I use to add a meta-data element that a native SDK expects at boot:

// plugins/withPaymentSdkMetadata.ts
import {
  AndroidConfig,
  ConfigPlugin,
  withAndroidManifest,
} from 'expo/config-plugins';

type Props = { merchantId: string };

export const withPaymentSdkMetadata: ConfigPlugin<Props> = (config, { merchantId }) => {
  return withAndroidManifest(config, (cfg) => {
    const mainApplication = AndroidConfig.Manifest.getMainApplicationOrThrow(cfg.modResults);

    // Idempotent: helper checks for an existing entry with the same name.
    AndroidConfig.Manifest.addMetaDataItemToMainApplication(
      mainApplication,
      'com.example.PAYMENT_SDK_MERCHANT_ID',
      merchantId,
    );

    return cfg;
  });
};

Two things to notice. getMainApplicationOrThrow gives you a stable reference to the first <application> node without you having to null-check array indices. And addMetaDataItemToMainApplication is idempotent by contract: it replaces an existing entry with the same android:name rather than appending a duplicate. That property is what makes npx expo prebuild (without --clean) usable during iteration. Re-running the plugin ten times gives the same manifest as running it once.

Adding permissions the right way

For permissions specifically, don't push into manifest['uses-permission'] by hand. Use AndroidConfig.Permissions.addPermission and pair it with the plugin's _internal.pluginHistory so you can remove permissions when the plugin is removed:

import { AndroidConfig, ConfigPlugin, withAndroidManifest } from 'expo/config-plugins';

export const withCameraPermission: ConfigPlugin = (config) => {
  return withAndroidManifest(config, (cfg) => {
    AndroidConfig.Permissions.addPermission(cfg.modResults, 'android.permission.CAMERA');
    return cfg;
  });
};

Writing a withXcodeProject mod

withXcodeProject is the heaviest of the built-in mods because project.pbxproj is a binary-encoded plist that Expo parses with the xcode npm package. The modResults handle you get is an XcodeProject instance with methods like addFramework, addBuildPhase, addTarget, and addSourceFile. Ninety percent of the time you're doing one of three things: linking a system framework, adding a shell script build phase, or setting a build setting on a target.

// plugins/withCoreMotion.ts
import { ConfigPlugin, withXcodeProject } from 'expo/config-plugins';

export const withCoreMotion: ConfigPlugin = (config) => {
  return withXcodeProject(config, (cfg) => {
    const project = cfg.modResults;

    // addFramework is idempotent when the same name is provided.
    project.addFramework('CoreMotion.framework', {
      link: true,
      target: project.getFirstTarget().uuid,
    });

    return cfg;
  });
};

For a custom build phase (think Sentry symbol upload, or a compliance script that verifies entitlements before archive), the shape is very similar:

import { ConfigPlugin, withXcodeProject } from 'expo/config-plugins';

const SCRIPT_NAME = 'Upload Debug Symbols';

export const withSentryUpload: ConfigPlugin = (config) => {
  return withXcodeProject(config, (cfg) => {
    const project = cfg.modResults;

    // Idempotency guard. Skip if a phase with the same name already exists.
    const target = project.getFirstTarget().uuid;
    const buildPhases = project.pbxItemByComment(SCRIPT_NAME, 'PBXShellScriptBuildPhase');
    if (buildPhases) return cfg;

    project.addBuildPhase(
      [],
      'PBXShellScriptBuildPhase',
      SCRIPT_NAME,
      target,
      {
        shellPath: '/bin/sh',
        shellScript: '"$SRCROOT/../scripts/upload-sentry.sh"',
      },
    );
    return cfg;
  });
};

Notice the explicit idempotency check. The addBuildPhase API will happily add duplicate phases if you call it twice, and duplicate script phases run twice per build. I hit this exact bug shipping our first Sentry integration, and it was fun explaining to finance why symbol uploads doubled overnight.

Combining iOS and Android in one plugin

Most real-world plugins need to touch both platforms. The pattern is simply to chain mods:

import {
  AndroidConfig,
  ConfigPlugin,
  withAndroidManifest,
  withInfoPlist,
  createRunOncePlugin,
} from 'expo/config-plugins';

type Props = { apiKey: string };

const withPaymentSdk: ConfigPlugin<Props> = (config, { apiKey }) => {
  config = withInfoPlist(config, (cfg) => {
    cfg.modResults['PaymentSdkApiKey'] = apiKey;
    return cfg;
  });

  config = withAndroidManifest(config, (cfg) => {
    const mainApplication = AndroidConfig.Manifest.getMainApplicationOrThrow(cfg.modResults);
    AndroidConfig.Manifest.addMetaDataItemToMainApplication(
      mainApplication,
      'com.example.PAYMENT_SDK_API_KEY',
      apiKey,
    );
    return cfg;
  });

  return config;
};

const pkg = { name: 'expo-payment-sdk-plugin', version: '2.0.0' };
export default createRunOncePlugin(withPaymentSdk, pkg.name, pkg.version);

The order of mods only matters when two mods touch the same file. Expo runs mods in the order you register them, and each mod sees the mutations of the previous one. In practice, I organize plugins so that platform-specific mods live in their own with<Feature>Ios.ts and with<Feature>Android.ts files and get composed at the top-level entry point. It's the same file-layout discipline I described in my Turborepo monorepo guide for our platform team's shared plugins.

Dangerous mods and when to reach for them

Dangerous mods are with<Platform>DangerousMod escape hatches that let you read and write arbitrary files under ios/ or android/ during prebuild. They're the tool of last resort for two situations: touching Swift/Kotlin source files that no built-in mod covers (a Push Notification Service Extension, an App Clip target), or writing an asset file Expo's asset pipeline doesn't yet understand. The word "dangerous" isn't decoration. These mods bypass Expo's parsing helpers and give you a raw filesystem handle.

import fs from 'node:fs/promises';
import path from 'node:path';
import { ConfigPlugin, withDangerousMod } from 'expo/config-plugins';

export const withPodfileEntry: ConfigPlugin = (config) => {
  return withDangerousMod(config, [
    'ios',
    async (cfg) => {
      const podfilePath = path.join(cfg.modRequest.platformProjectRoot, 'Podfile');
      const podfile = await fs.readFile(podfilePath, 'utf8');

      const marker = "pod 'MyPrivatePod'";
      if (podfile.includes(marker)) return cfg; // idempotent

      const updated = podfile.replace(
        /target 'MyApp' do/,
        (match) => `${match}\n  ${marker}, :path => '../vendor/MyPrivatePod'`,
      );

      await fs.writeFile(podfilePath, updated);
      return cfg;
    },
  ]);
};

Publishing a config plugin as an npm package

Local plugins are fine for app-specific glue, but anything another team, app, or open-source project might reuse should ship as a package. The convention Expo enforces is minimal: your package's root must contain an app.plugin.js file that re-exports the compiled plugin's default. Everything else (package.json, tsconfig.json, build/ output) follows normal npm conventions.

// app.plugin.js (at the package root, hand-written)
module.exports = require('./build/withPaymentSdk').default;
// package.json
{
  "name": "expo-payment-sdk-plugin",
  "version": "2.0.0",
  "main": "build/index.js",
  "types": "build/index.d.ts",
  "files": ["build", "app.plugin.js"],
  "scripts": {
    "build": "tsc",
    "prepublishOnly": "npm run build"
  },
  "peerDependencies": {
    "expo": ">=53.0.0"
  },
  "devDependencies": {
    "@types/node": "^22.0.0",
    "expo": "^55.0.0",
    "typescript": "^5.6.0"
  }
}

Two production notes. Pin expo as a peerDependency, not a regular dependency; you want the app's version of Expo, not yours. And declare a widest reasonable range: >=53.0.0 covers every SDK where the current config-plugins API surface has been stable. My team's plugins survived the SDK 53 to 55 jump documented in the Expo SDK 55 migration guide without a single change. Not bragging, just noting that the API really is stable in practice.

Debugging and testing config plugins

Debugging config plugins is more pleasant than it looks. Two commands cover 90% of the workflow. First, npx expo config --type introspect serializes the fully-applied config, showing every mod's effect on Info.plist and AndroidManifest.xml without actually writing native files. That's the fastest feedback loop for iteration. Second, EXPO_DEBUG=true npx expo prebuild --clean --no-install forces a fresh generation with verbose logging, so you can see the exact order in which your mods run and which files they touched. See the official plugin debugging docs for the full flag list.

npx expo config --type introspect | jq '.mods'
# Lists every mod that will run, in order, without touching disk.

EXPO_DEBUG=true npx expo prebuild --clean --no-install
# Verbose prebuild. Useful when a mod silently misbehaves.

For unit tests, treat each mod as a pure function. Import it, hand it a synthetic config object, and assert on modResults. I keep a fixtures folder of "empty Info.plist," "empty AndroidManifest.xml," and "typical project.pbxproj" that every plugin gets snapshotted against in CI. When you eventually break a plugin during an SDK upgrade (and you will), the snapshot diff tells you exactly which mod changed shape.

A platform team checklist for 2026

If you're running a platform team and adopting config plugins, the following is the review checklist I inherited (and then bullied into a GitHub PR template). It's short on purpose. The point is that every plugin merged into main answers these questions the same way.

  1. Typed props. Does the plugin use ConfigPlugin<Props> with an exported Props type? If not, consumers get untyped junk in their app.json.
  2. Prop validation. Does the plugin throw a clear, prefixed error on missing/invalid props before any mod runs?
  3. Idempotency. Can I run npx expo prebuild twice back-to-back and get the same output? Every helper you use should be idempotent, or you must add your own guard.
  4. run-once wrap. Is the exported default wrapped in createRunOncePlugin with the package's own name and version?
  5. Zero I/O in the plugin function. No fs, no fetch, no synchronous heavy computation outside a mod. Plugins run dozens of times per session.
  6. Snapshot test. At least one fixture-based test per mod, checked into the repo, wired into CI.
  7. SDK compatibility. Peer-dependency range documented, tested against the current and prior Expo SDK.

That's genuinely the whole discipline. Plugins are small, deterministic, and testable when you treat them that way, and they turn what used to be an upgrade nightmare into a diff you can review over coffee. If you're also modernizing off the old bridge architecture, my React Native New Architecture migration checklist is the companion piece I'd pair with this one.

Frequently Asked Questions

Do I need to run expo prebuild every time I change a config plugin?

Yes. Mods only execute during the syncing phase of npx expo prebuild. If you change a plugin's mod logic, you must re-run prebuild for the change to hit Info.plist, AndroidManifest.xml, or the Xcode project. For pure prop changes you can usually re-run without --clean as long as your plugin is idempotent; for structural changes always use --clean.

Can I publish an Expo config plugin as an npm package?

Yes. Add an app.plugin.js file at the package root that re-exports the compiled plugin's default export, declare expo as a peer dependency, and include build/ and app.plugin.js in your package.json files field. Consumers then reference the plugin by package name in app.json's plugins array.

What is the difference between a config plugin and a mod?

A config plugin is a synchronous function that adds one or more mods to your Expo config. A mod is an async function that Expo invokes during prebuild to modify a specific native file. Plugins run every time getConfig is called; mods run only during expo prebuild. Put validation and metadata in the plugin, put file mutation in the mod.

How do I debug a config plugin that is not applying changes?

Run npx expo config --type introspect to see the fully-serialized config and the list of registered mods without touching disk, then run EXPO_DEBUG=true npx expo prebuild --clean to get verbose mod-execution logs. If your plugin uses dangerous mods, add console.log calls inside the mod (they'll appear in the prebuild output).

Are Expo config plugins compatible with the New Architecture?

Yes. Config plugins operate at the native-project-file level, which is orthogonal to whether your app uses Fabric and TurboModules. Expo SDK 53+ enables the New Architecture by default, and every built-in mod (withXcodeProject, withAndroidManifest, withInfoPlist, withGradleProperties) works identically on both architectures.

Yelena Petrov
About the Author Yelena Petrov

React Native architect at a fintech. Builds platform teams, type-safe bridges, and runs the upgrade playbook so others don't have to.