Config Plugins and Prebuild

Change native project files from your app config, run expo prebuild, and stop hand-editing the ios and android folders.

Syntaxconst withX: ConfigPlugin<Options> = (config, options) => config

Some native changes are not code: a permission string, a URL scheme, a Gradle property, an entitlement. A config plugin is a JavaScript function that makes such a change to the generated native project. It is referenced from the plugins array in your app config and runs during prebuild.

Prebuild and CNG

npx expo prebuild generates the ios/ and android/ folders from a template plus your config, then applies every plugin. This is Continuous Native Generation: the native projects are build output, not source. The usual setup adds /ios and /android to .gitignore and keeps the app config as the single source of truth. Use npx expo prebuild --clean to delete and regenerate, and --platform ios or --platform android to do one side only.

Writing one

A plugin is typed ConfigPlugin and has the shape (config) => config. Inside it you call a mod — withInfoPlist, withAndroidManifest, withGradleProperties and friends, all from expo/config-plugins — and edit config.modResults, which holds the parsed file. Return the config from both the mod callback and the plugin.

What trips people up

Hand-editing ios/ or android/. The next prebuild overwrites it, and nothing warns you except a vanished setting. The second trap is that plugins are not always idempotent: one that appends rather than checking first will add a duplicate on every run, so prefer --clean.

Example

Example · tsx
// plugins/withNfcUsage.ts
import type { ConfigPlugin } from "expo/config-plugins";
import { withAndroidManifest, withInfoPlist } from "expo/config-plugins";

type Options = { reason: string };

const NFC_PERMISSION = "android.permission.NFC";

const withNfcUsage: ConfigPlugin<Options> = (config, { reason }) => {
  // iOS: modResults is the parsed Info.plist as a plain object.
  config = withInfoPlist(config, (config) => {
    config.modResults.NFCReaderUsageDescription = reason;
    return config;
  });

  // Android: modResults is AndroidManifest.xml parsed into JSON, where
  // attributes live under the "$" key.
  config = withAndroidManifest(config, (config) => {
    const manifest = config.modResults.manifest;
    manifest["uses-permission"] = manifest["uses-permission"] ?? [];

    // Check before adding, or a second prebuild duplicates the entry.
    const already = manifest["uses-permission"].some(
      (entry) => entry.$["android:name"] === NFC_PERMISSION
    );
    if (!already) {
      manifest["uses-permission"].push({ $: { "android:name": NFC_PERMISSION } });
    }

    return config;
  });

  return config;
};

export default withNfcUsage;

When to use it

  • Add an iOS permission usage string or an Android permission that a library's README asks for.
  • Set a Gradle property such as a larger JVM heap so CI builds stop running out of memory.
  • Register a custom URL scheme or associated domain for deep links without opening Xcode.

More examples

Referencing it from the app config

A plugin entry is either a bare string (no options) or a two-item array of the plugin and its options object — the same thing, with the string form being shorthand for an array of one. A local plugin is referenced by relative path. Order matters: plugins run top to bottom, so a plugin that reads a file another plugin writes must come after it.

Example · json
{
  "expo": {
    "name": "Tap",
    "slug": "tap",
    "ios": { "bundleIdentifier": "com.example.tap" },
    "android": { "package": "com.example.tap" },
    "plugins": [
      "expo-router",
      [
        "./plugins/withNfcUsage",
        { "reason": "Tap reads NFC tags to check guests in." }
      ]
    ]
  }
}

A Gradle property, made idempotent

Gradle running out of heap is a common CI failure, and the fix lives in a generated file. Filtering the existing key before pushing makes the plugin idempotent — the pattern to copy whenever you edit a list rather than set a field. Reach for withGradleProperties, withAppBuildGradle or withPodfile when a library's install instructions tell you to edit those files by hand.

Example · ts
// plugins/withLargeHeap.ts
import type { ConfigPlugin } from "expo/config-plugins";
import { withGradleProperties } from "expo/config-plugins";

const withLargeHeap: ConfigPlugin = (config) =>
  withGradleProperties(config, (config) => {
    // modResults here is an array of parsed gradle.properties lines.
    config.modResults = config.modResults.filter(
      (item) => !(item.type === "property" && item.key === "org.gradle.jvmargs")
    );

    config.modResults.push({
      type: "property",
      key: "org.gradle.jvmargs",
      value: "-Xmx4g -XX:MaxMetaspaceSize=1g",
    });

    return config;
  });

export default withLargeHeap;

The prebuild commands you actually run

The verify step is the habit worth keeping: run prebuild, grep the generated file, confirm the plugin wrote what you expected. A plugin that throws stops prebuild with a stack trace, but a plugin that writes the wrong key succeeds silently and the app fails on a device weeks later. Note that prebuild also touches package.json scripts and dependency versions, so review the diff before committing.

Example · bash
# Generate the native projects from app.json plus every config plugin.
npx expo prebuild

# Safer: delete and regenerate. Use this after changing a plugin, because
# a non-idempotent plugin can double its own edits on a second pass.
npx expo prebuild --clean

# One platform only, when you are iterating on an iOS-side plugin.
npx expo prebuild --clean --platform ios

# Inspect what a plugin produced, then discard the folders again.
grep -n "NFCReaderUsageDescription" ios/*/Info.plist
grep -n "android.permission.NFC" android/app/src/main/AndroidManifest.xml

# Build and install on a device or simulator using the generated project.
npx expo run:ios
npx expo run:android

Discussion

  • Be the first to comment on this lesson.