Expo config plugins in production: the guide that prevents native drift
Every Expo developer meets the config plugin moment eventually. You need a native SDK — push notifications, Sign in with Apple, a Bluetooth library — and the managed workflow suddenly asks you to edit AndroidManifest.xml and Info.plist files that do not exist in your project. That is what config plugins solve: small functions that patch native project files at prebuild time, reproducibly, in version control.
This guide covers how config plugins actually work, the production patterns that keep them maintainable, and the escape hatches for when a library ships without one.
What a config plugin really does
An Expo project has no ios/ or android/ directories. When you run npx expo prebuild, Expo generates those native projects from your app.json/app.config.js plus every config plugin in your dependency tree. Each plugin is a JavaScript function that receives the Expo config object and mutates it — adding a permissions string, registering a background mode, injecting a Gradle dependency — before the native files are written.
The key mental model: your app.json is source, the native projects are build artifacts. You never edit generated files by hand because the next prebuild regenerates them. If a native change cannot be expressed as config or a plugin, it does not survive. Teams that internalize this early stop fighting the system; teams that hand-edit android/ after prebuild rediscover the lesson every upgrade.
Most of the time you never write a plugin — you consume them. Libraries like expo-notifications or expo-router ship plugins that activate automatically when the package is installed. You only write custom plugins for the gaps: a third-party SDK with an expo install-able package but no plugin, or a native setting Expo does not expose.
Reading plugin props like a production checklist
A mature plugin exposes its knobs as props in app.json. Before adopting any native library, read its plugin props the way you would read a contract:
// app.config.js — every native capability, explicit and reviewable
export default {
expo: {
name: 'Acme',
slug: 'acme',
plugins: [
'expo-router',
[
'expo-notifications',
{ icon: './assets/notification-icon.png', color: '# PLACEHOLDER' },
],
[
'expo-build-properties',
{
android: { compileSdkVersion: 34, minSdkVersion: 24 },
ios: { deploymentTarget: '15.1' },
},
],
],
},
}Three habits pay off here. First, prefer app.config.js over app.json once you have more than a handful of plugins — comments and shared constants prevent the mystery-prop problem six months later. Second, pin expo-build-properties values deliberately and record why: the SDK versions you choose determine which devices install your app and which Play Store policies apply. Third, review the plugin list in every PR the way you would review native diffs, because that is what they are — each entry mutates the binary your users install.
For auth-gated apps, this pairs with route-level guards covered in our Expo Router auth guards guide: plugins configure what the native shell can do, guards control what the user can see.
One codebase. iOS, Android, and web.
The Fitness Kit ships with auth, a database, and a backend already connected — no setup. Live demo at fitness-preview.otf-kit.dev.
Writing your first custom plugin
When a library lacks a plugin, you have two options: patch-package the native files (fragile, dies on prebuild) or write a minimal plugin (durable, reviewable). A plugin is less intimidating than it looks — most custom plugins are under 30 lines:
// plugins/with-custom-sdk.ts — a minimal config plugin
import { ConfigPlugin, withInfoPlist, withAndroidManifest } from 'expo/config-plugins'
const withCustomSdk: ConfigPlugin<{ apiKey: string }> = (config, { apiKey }) => {
config = withInfoPlist(config, (config) => {
config.modResults.NSCustomSDKKey = apiKey
config.modResults.UIBackgroundModes = [
...new Set([...(config.modResults.UIBackgroundModes ?? []), 'remote-notification']),
]
return config
})
config = withAndroidManifest(config, (config) => {
const manifest = config.modResults.manifest
manifest['uses-permission'] = [
...(manifest['uses-permission'] ?? []),
{ $: { 'android:name': 'android.permission.POST_NOTIFICATIONS' } },
]
return config
})
return config
}
export default withCustomSdkNote the defensive patterns: spread existing UIBackgroundModes through a Set instead of overwriting (another plugin may have added entries), and append permissions rather than replacing the array. Plugins compose in sequence, and the plugin that clobbers shared arrays breaks every plugin after it. Always merge, never assign.
Register it in config with a relative path, keep it in plugins/ at the repo root, and add tests that run the plugin against a fixture config and assert on the output — you can unit-test native configuration without ever opening Xcode.
The prebuild discipline that prevents disasters
Config plugins only run at prebuild. That single fact dictates your whole workflow. Continuous Expo Application Services builds run prebuild for you on every build, which means your EAS build always reflects your current plugins. Local expo run:ios / run:android runs prebuild implicitly too. Problems start when developers generate ios/ and android/ once, check them in, and then change plugin config without regenerating — the binary silently diverges from the declared config.
The production setup: never commit generated ios//android/ directories (gitignore them), always let the build service generate fresh, and run npx expo prebuild --clean locally when debugging plugin behavior so stale artifacts cannot mask a broken plugin. If you must keep generated directories (rare, usually for native debugging), regenerate them in CI and fail the build if the working tree is dirty afterward — drift becomes a build error instead of a mystery crash.
This discipline also protects your secrets workflow. Build-time secrets and environment profiles belong in EAS configuration, as detailed in our EAS secrets and env profiles guide — never hardcode keys into plugin props that get committed.
Debugging plugins without losing a day
When a build fails after adding a plugin, bisect in this order. First, run npx expo config --type prebuild to see the fully resolved config — most plugin bugs are visible here as missing or duplicated entries. Second, inspect the generated native project (ios/ or android/ after a local prebuild) and diff the specific file the plugin claims to modify. Third, check plugin ordering: plugins run in array order, and two plugins touching the same manifest section can conflict — reorder so the more specific plugin runs last.
Version skew is the other classic failure. A plugin written against Expo SDK 50 APIs can break silently on SDK 52 if the underlying config schema changed. The fix is boring but effective: upgrade all Expo packages in lockstep with npx expo install --fix, read the SDK changelog's config-plugin section, and never let one library pin an older @expo/config-plugins version while the rest move on. Your lockfile is part of your native configuration whether you treat it that way or not.
When to eject from the plugin model
Rarely, but honestly: if your app needs deep native customization — custom AppDelegate logic, forked native modules, build flavors with divergent native code — maintaining that as plugins becomes more convoluted than maintaining the native projects directly. That threshold is much higher than most teams think; the common estimate is that fewer than one in twenty Expo apps genuinely needs to eject, a myth we unpack in our eject-button myth post. Try the plugin for a week before concluding it cannot work — and if you do eject, keep a written record of which plugins you replaced with hand edits, because every future SDK upgrade now requires manual reconciliation of exactly those files.
Sources
- Expo config plugins introduction — the official plugin model and authoring reference.
- Our Expo Router auth guards guide — route-level protection for your plugin-configured shell.
- Our EAS secrets and env profiles guide — where build-time secrets belong.
- Our eject-button myth post — when the plugin model genuinely runs out.
Stop wiring. Start shipping.
- Login, database, and backend already connected — nothing to set up
- iOS + Android + web from one codebase
- AI configs pre-tuned + 40+ tested prompts included