Config Plugins and Prebuild
Change native project files from your app config, run expo prebuild, and stop hand-editing the ios and android folders.
const withX: ConfigPlugin<Options> = (config, options) => configSome 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
// 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.
{
"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.
// 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.
# 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