Shipnative
ShipnativeShipnative
Sign in
GuideAugust 2026 · 10 min read

Expo Config Plugins: Edit Native Code Without Opening Xcode

Config plugins are the answer to a question every Expo project eventually asks: “the docs say add this key to Info.plist — where isInfo.plist?” In a modern Expo app the native folders are generated, not authored, so editing them by hand works exactly until the next prebuildsilently throws your change away. A config plugin is how you make that edit permanent, reviewable, and reproducible on a teammate’s machine and in CI.

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:

ModTouchesTypical use
withInfoPlistios/<App>/Info.plistPermission strings, URL schemes, ATS exceptions
withAndroidManifestandroid/.../AndroidManifest.xmlPermissions, intent filters, meta-data tags
withEntitlementsPlistios entitlementsApp Groups, Keychain sharing, associated domains
withAppBuildGradleandroid/app/build.gradleDependencies, packaging options, flavours
withProjectBuildGradleandroid/build.gradleMaven repositories, Gradle plugin versions
withGradlePropertiesandroid/gradle.propertiesHeap size, feature flags, library settings
withAndroidStylesandroid styles.xmlTheme attributes, status bar appearance
withXcodeProjectthe .pbxprojBuild phases, target settings, resource files
withDangerousModany file, directlyLast 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:

  1. Committed native folders. If ios/ is in git, prebuild merges rather than regenerates and old state lingers. --clean is the reset button.
  2. 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.
  3. 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.

Frequently Asked Questions

What is an Expo config plugin?

A JavaScript function that modifies your native project during npx expo prebuild. It receives the resolved app config and returns a modified one, with helpers that let it edit Info.plist, AndroidManifest.xml, build.gradle, entitlements, and raw files. It turns a manual Xcode edit into a line of committed configuration.

Why do my native changes keep disappearing?

Because you edited the generated ios or android folders directly and something ran prebuild again, which regenerates them from your config. In a Continuous Native Generation project those folders are build output, not source. Any change that must persist has to be expressed as a config plugin or an app config field.

Do I need a config plugin to use expo-camera or expo-location?

No — those libraries ship their own plugins. You add them to the plugins array in app.json with props for the permission strings, and the library handles the native edits. You only write your own plugin when a library has no plugin, or when you need a native change no library covers.

Do config plugins work in Expo Go?

No. Expo Go runs a fixed native binary containing a fixed set of modules, so there is nothing for a plugin to modify. Config plugins take effect during prebuild, which means a development build or an EAS build.

How do I check what a config plugin actually did?

Run npx expo prebuild --clean and read the generated files — ios/YourApp/Info.plist and android/app/src/main/AndroidManifest.xml are where most changes land. You can also run npx expo config --type prebuild to print the fully resolved config, with every plugin applied, before any files are written.

Should I commit the ios and android folders?

Prefer not to. Leaving them out keeps your native layer reproducible from config, makes Expo SDK upgrades far easier, and means prebuild --clean is always safe. Commit them only when you have native code that genuinely cannot be expressed as a plugin — and accept that you now own those files by hand.

→

Expo Go vs Development Build

Why plugins only take effect in one of them.

Read guide →
→

Expo EAS Submission Checklist

The permission strings App Review actually reads.

See checklist →

Ship a real React Native app today

Describe, preview, and export Expo code — free to start.

Build with ShipNative →
ShipNative logoShipnative

Build mobile apps with AI. Describe, preview, and ship to iOS & Android in minutes.

Features

Text to App AIApp Generator from ScreenshotPRD to Mobile App

Tools

All free toolsApp Cost CalculatorApp Name GeneratorApp Store Keyword ToolReact Native Components

Blog

All blog postsHow to Build an App Without CodingBest AI Tools for Real Mobile AppsExpo EAS App Store ChecklistLovable, Cursor & v0 for MobileBest AI App Builders in 2026React Native AI App Builder Guide

Legal

FAQTerms of ServicePrivacy Policy

© 2026 ShipNative. All rights reserved.