Config Plugins
A cookbook for app.config plugins that modify Info.plist, AndroidManifest.xml, and Gradle - the CNG-safe way to wire native modules without manual drift in ios/ and android/.
Search across all documentation pages
A cookbook for app.config plugins that modify Info.plist, AndroidManifest.xml, and Gradle - the CNG-safe way to wire native modules without manual drift in ios/ and android/.
Quick-reference recipe card - copy-paste ready.
// app.config.ts
import type { ExpoConfig } from "expo/config";
export default ({ config }: { config: ExpoConfig }): ExpoConfig => ({
...config,
plugins: [
// Built-in permission plugins (Info.plist + AndroidManifest)
[
"expo-camera",
{
cameraPermission: "Allow $(PRODUCT_NAME) to scan product barcodes",
microphonePermission: false,
},
],
// Build-time properties (Gradle, Podfile properties)
[
"expo-build-properties",
{
ios: { deploymentTarget: "15.1" },
android: { minSdkVersion: 24, compileSdkVersion: 35 },
},
],
// Local plugin for custom native changes
"./plugins/with-custom-url-scheme",
],
});# Apply plugins and inspect output
npx expo prebuild --clean
git diff ios/ android/ # review generated native changes
# Idempotency check - second run should produce no new diff
npx expo prebuild
git diff ios/ android/ # expect emptyWhen to reach for this:
NSCameraUsageDescription) required by native modules.queries entries for package visibility.minSdkVersion, ProGuard rules, or iOS deployment targets for a native SDK.// app.config.ts
import type { ExpoConfig } from "expo/config";
export default ({ config }: { config: ExpoConfig }): ExpoConfig => ({
...config,
name: "ConfigPluginCookbook",
slug: "config-plugin-cookbook",
plugins: [
[
"expo-camera",
{
cameraPermission:
"Allow $(PRODUCT_NAME) to take photos for your profile",
microphonePermission:
"Allow $(PRODUCT_NAME) to record audio with video",
recordAudioAndroid: true,
},
],
],
});After npx expo prebuild, iOS receives:
<!-- ios/<App>/Info.plist (generated) -->
<key>NSCameraUsageDescription</key>
<string>Allow ConfigPluginCookbook to take photos for your profile</string>
<key>NSMicrophoneUsageDescription</key>
<string>Allow ConfigPluginCookbook to record audio with video</string>Android receives:
<!-- android/app/src/main/AndroidManifest.xml (generated excerpt) -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />// plugins/with-custom-url-scheme.ts
import type { ConfigPlugin } from "expo/config-plugins";
import {
AndroidConfig,
IOSConfig,
createRunOncePlugin,
withAndroidManifest,
withInfoPlist,
} from "expo/config-plugins";
const SCHEME = "myshop";
const withCustomUrlScheme: ConfigPlugin = (config) => {
config = withInfoPlist(config, (mod) => {
const urlTypes = mod.modResults.CFBundleURLTypes ?? [];
const exists = urlTypes.some(
(entry) =>
Array.isArray(entry.CFBundleURLSchemes) &&
entry.CFBundleURLSchemes.includes(SCHEME)
);
if (!exists) {
urlTypes.push({
CFBundleURLName: "com.example.myshop",
CFBundleURLSchemes: [SCHEME],
});
mod.modResults.CFBundleURLTypes = urlTypes;
}
return mod;
});
config = withAndroidManifest(config, (mod) => {
const manifest = mod.modResults;
const app = AndroidConfig.Manifest.getMainApplicationOrThrow(manifest);
// Deep link intent filter
AndroidConfig.Manifest.addIntentFilter(app, {
action: "android.intent.action.VIEW",
category: ["android.intent.category.DEFAULT", "android.intent.category.BROWSABLE"],
data: [{ scheme: SCHEME }],
});
return mod;
});
return config;
};
export default createRunOncePlugin(withCustomUrlScheme, "with-custom-url-scheme", "1.0.0");// app.config.ts - register local plugin
import type { ExpoConfig } from "expo/config";
export default ({ config }: { config: ExpoConfig }): ExpoConfig => ({
...config,
plugins: ["./plugins/with-custom-url-scheme"],
});What this demonstrates:
withInfoPlist mutates iOS metadata idempotently - checks before appending URL types.withAndroidManifest adds intent filters without regex-append drift.createRunOncePlugin prevents duplicate application when prebuild runs multiple times.npx expo prebuild --clean - the CNG contract per Prebuild Basics.// app.config.ts excerpt - native SDK requires higher compileSdk
plugins: [
[
"expo-build-properties",
{
android: {
minSdkVersion: 24,
compileSdkVersion: 35,
targetSdkVersion: 35,
extraProguardRules: `
-keep class com.vendor.sdk.** { *; }
`,
},
ios: {
deploymentTarget: "15.1",
useFrameworks: "static",
},
},
],
],See Patching Native Projects for advanced Gradle survival strategies.
app.config.ts / app.json
│
▼
plugins: [ "expo-camera", "./plugins/with-custom-url-scheme" ]
│
▼ (sequential, in array order)
Mod plugins: withInfoPlist, withAndroidManifest, withGradleProperties, …
│
▼
ios/ android/ written to disk
| Mod helper | Target file | Typical use |
|---|---|---|
withInfoPlist | Info.plist | Usage descriptions, URL schemes, background modes |
withAndroidManifest | AndroidManifest.xml | Permissions, intent filters, queries |
withGradleProperties | gradle.properties | JVM args, Android flags |
withAppBuildGradle | app/build.gradle | Dependencies, packaging options |
withEntitlementsPlist | .entitlements | Associated domains, push, keychain groups |
When two plugins modify the same key, later plugins win. Document order in app.config.ts:
plugins: [
"expo-router", // Must run before plugins that add splash assets
"expo-camera", // Permissions first
"./plugins/with-custom-url-scheme", // App-specific deep links after router
[
"expo-build-properties", // Build props last - may depend on prior structure
{ android: { minSdkVersion: 24 } },
],
],// app.config.ts - dynamic (env-aware)
import type { ExpoConfig } from "expo/config";
export default ({ config }: { config: ExpoConfig }): ExpoConfig => ({
...config,
extra: {
apiUrl: process.env.EXPO_PUBLIC_API_URL,
},
plugins: [
[
"react-native-maps",
{
iosGoogleMapsApiKey: process.env.GOOGLE_MAPS_IOS_KEY ?? "",
},
],
],
});Secrets belong in EAS environment variables, not committed .env files referenced at prebuild time in CI.
# Verbose prebuild logs
EXPO_DEBUG=1 npx expo prebuild --clean
# Diff only Info.plist
npx expo prebuild --platform ios --clean
diff ios/MyApp/Info.plist ios/MyApp/Info.plist.bak 2>/dev/null || true
# Validate with doctor
npx expo-doctorNS*UsageDescription strings - iOS crashes at permission request time. Fix: Use the library's config plugin or add withInfoPlist entries.createRunOncePlugin.--clean. Fix: Move change into a plugin per Config Plugins in CNG.<queries>. Fix: withAndroidManifest adds queries entries.| Alternative | Use When | Don't Use When |
|---|---|---|
| Built-in Expo config plugin | Library ships expo-camera, expo-location, etc. | Vendor lib has no plugin |
Local expo/config-plugins mod | One-off entitlements or manifest entries | Large native fork - write proper Expo module |
expo-build-properties | SDK versions, ProGuard, deployment target | Complex multi-file Gradle surgery |
| Brownfield manual native edits | Legacy app not on CNG | Standard SDK 57 greenfield apps |
A function registered in app.config plugins array that runs during npx expo prebuild and modifies native project files programmatically.
Built-in plugins ship inside Expo SDK packages (expo-camera, expo-build-properties). Custom plugins live in your repo - commonly plugins/with-*.ts.
Yes. EAS Build runs prebuild (unless disabled), executing the same plugin pipeline as local npx expo prebuild.
Run npx expo prebuild twice. git diff ios/ android/ after the second run must be empty.
A native module implements runtime JS-to-native calls. A config plugin configures the native project so the module compiles and has permissions - they often ship together.
expo-build-properties deep diveStack versions: This page was written for React 19.2.3, React Native 0.86.0, and Expo SDK 57 (
expo~57.0.4).
Reviewed by Chris St. John·Last updated Jul 16, 2026