The model: native folders are build output
Expo calls this Continuous Native Generation. Your source of truth is app.json (or app.config.js) plus your dependencies. Running npx expo prebuild generates ios/ and android/ from those inputs — the same way node_modules is generated from package.json.
That reframing explains the most common frustration in Expo. A hand edit to Info.plist is not lost because something is broken; it is lost because you edited a generated artifact. Plugins are the supported way to influence generation:
app.json + package.json
│
├── expo prebuild ──▶ ios/ android/ (regenerated, disposable)
│ │
└── config plugins ────────┘ (your native edits, as code)The payoff is real: SDK upgrades stop being merge conflicts in .pbxproj, and a fresh clone builds identically without anyone remembering an undocumented Xcode checkbox.
Using plugins other people wrote
Ninety per cent of the time you never write a plugin — you configure one. Most Expo libraries that need native changes ship a plugin, and you enable it in the plugins array, with props when it takes them:
{
"expo": {
"plugins": [
"expo-router",
[
"expo-camera",
{ "cameraPermission": "We use the camera so you can attach a photo to a report." }
],
[
"expo-location",
{ "locationAlwaysAndWhenInUsePermission": "We use your location to show nearby jobs." }
],
"./plugins/withCustomScheme"
]
}
}Those permission strings are not decoration — they are the sentences shown in the iOS system prompt, and App Review rejects vague ones. “This app requires camera access” is a rejection; the version above, naming the user-visible benefit, is not. A string entry is shorthand for the array form with no props, and order matters: plugins run top to bottom, so one that reads a value another writes must come second.
The mods worth knowing
A plugin is built from mods — helpers that hand you one parsed native file, let you change it, and write it back. Pick by the file you need to touch:
| Mod | Touches | Typical use |
|---|---|---|
withInfoPlist | ios/<App>/Info.plist | Permission strings, URL schemes, ATS exceptions |
withAndroidManifest | android/.../AndroidManifest.xml | Permissions, intent filters, meta-data tags |
withEntitlementsPlist | ios entitlements | App Groups, Keychain sharing, associated domains |
withAppBuildGradle | android/app/build.gradle | Dependencies, packaging options, flavours |
withProjectBuildGradle | android/build.gradle | Maven repositories, Gradle plugin versions |
withGradleProperties | android/gradle.properties | Heap size, feature flags, library settings |
withAndroidStyles | android styles.xml | Theme attributes, status bar appearance |
withXcodeProject | the .pbxproj | Build phases, target settings, resource files |
withDangerousMod | any file, directly | Last resort — no structure, no safety net |
Reach for the specific mod over withDangerousModwhenever one exists. The typed mods parse the file, apply your change to a data structure, and serialise it — so they survive Expo changing the file’s formatting. Dangerous mods do string surgery on whatever is on disk and break the first time that formatting shifts.
Writing one: a real example
Say a payment SDK needs a custom URL scheme on iOS and a queryable package on Android — a genuinely common requirement with no library plugin to install. Two mods, one file:
// plugins/withPaymentSdk.js
const { withInfoPlist, withAndroidManifest } = require('@expo/config-plugins');
const withIosScheme = (config, { scheme }) =>
withInfoPlist(config, (cfg) => {
const types = cfg.modResults.CFBundleURLTypes ?? [];
const already = types.some((t) => (t.CFBundleURLSchemes ?? []).includes(scheme));
if (!already) types.push({ CFBundleURLSchemes: [scheme] }); // idempotent: run twice, one entry
cfg.modResults.CFBundleURLTypes = types;
return cfg;
});
const withAndroidQuery = (config, { package: pkg }) =>
withAndroidManifest(config, (cfg) => {
const manifest = cfg.modResults.manifest;
manifest.queries = manifest.queries ?? [{}];
const q = manifest.queries[0];
q.package = q.package ?? [];
if (!q.package.some((p) => p.$['android:name'] === pkg)) {
q.package.push({ $: { 'android:name': pkg } });
}
return cfg;
});
module.exports = (config, props = {}) => {
const scheme = props.scheme ?? 'myapp-pay';
const pkg = props.package ?? 'com.example.wallet';
config = withIosScheme(config, { scheme });
config = withAndroidQuery(config, { package: pkg });
return config;
};The shape to copy: a plugin is (config, props) => config. Each mod gets cfg.modResults — the parsed contents of one native file — mutates it, and returns it. Compose several by threading config through them, or use withPlugins from the same package.
Write every mod idempotently. Prebuild can run more than once against a state you did not predict, and the classic bug is a plugin that appends a permission each run until the manifest holds six copies and the Android build fails on a duplicate. Both mods above check before they push, which is all idempotency usually costs.
Verifying and debugging
Plugins fail quietly — a mod that returns the wrong object or matches nothing produces no error, just a native file without your change. Check the output rather than trusting the run:
npx expo config --type prebuild # resolved config with all plugins applied npx expo prebuild --clean # delete and regenerate ios/ and android/ # then actually read the result: cat ios/*/Info.plist | grep -A3 CFBundleURLTypes cat android/app/src/main/AndroidManifest.xml | grep -A5 "<queries>"
The three failure modes, in order of frequency:
- Committed native folders. If
ios/is in git, prebuild merges rather than regenerates and old state lingers.--cleanis the reset button. - Forgetting to rebuild. Plugins change native code, so a running dev client will not pick them up. New native code means a new development build, every time.
- Testing in Expo Go. Nothing you write in a plugin can affect Expo Go — its binary is fixed and shared. Plugin work implies a development build.
When not to write one
Plenty of native settings already have first-class app-config fields — icon, splash, bundle identifier, orientation, deep-link scheme, most permissions. Check app.json before writing a mod for something Expo already models; a field is easier to read, upgrade-safe, and cannot be non-idempotent.
And if you find yourself writing hundreds of lines of dangerous mods, that is the signal you have outgrown generation and should either write a proper native module or commit the native folders deliberately. The useful boundary: plugins are for configuring native projects, not for authoring native features.
Start from a project that is already configured
Most apps need the standard set — camera, location, notifications, deep links — wired with permission strings that pass review. Describe your app at shipnative.dev and the generated React Native project arrives with those plugins configured, previewable on your phone, and exportable as a complete Expo project you own outright.