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.