Writing Your First Config Plugin
Create a withMyPlugin function using the @expo/config-plugins helpers, add it to app.json's plugins array, and run expo prebuild to verify the native output.
Writing Your First Config Plugin is a free React Native Academy lesson on CoddyKit — lesson 2 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the React Native Academy learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.
Project Setup for a Config Plugin
You can write a config plugin as a file inside your project or as a separate npm package. For project-specific plugins, create a plugins/ folder at the project root and add your plugin file there. Reference it in app.json using a relative path. This approach keeps the plugin co-located with the app and requires no publishing step.
# Project structure
your-expo-app/
plugins/
withCustomPermissions.js <- your plugin
app.json
App.tsx
# app.json reference
{
'expo': {
'plugins': [
['./plugins/withCustomPermissions', { 'reason': 'Scan QR codes' }]
]
}
}The withMyPlugin Function Signature
Every config plugin is a synchronous function that takes two arguments: config (the full Expo config object) and an optional options object passed from app.json. It must return the (modified) config object. You chain Expo's built-in modifier helpers inside this function to make specific changes to native files, then return the result of the last modifier call.
// plugins/withCustomPermissions.js
const { withInfoPlist } = require('@expo/config-plugins');
/**
* @param {import('@expo/config-plugins').ExpoConfig} config
* @param {{ reason: string }} options
*/
function withCustomPermissions(config, options = {}) {
const reason = options.reason || 'This app requires camera access.';
return withInfoPlist(config, (iosConfig) => {
iosConfig.modResults['NSCameraUsageDescription'] = reason;
return iosConfig;
});
}
module.exports = withCustomPermissions;Understanding modResults
Inside a mod callback (like the one passed to withInfoPlist), you receive a config object where config.modResults contains the parsed native file as a JavaScript object. For withInfoPlist it is a plain object mirroring the plist dictionary. For withAndroidManifest it is a parsed XML document. You modify modResults in place and return the config — Expo serializes it back to the native file format automatically.
const { withInfoPlist } = require('@expo/config-plugins');
module.exports = function withAppTrackingPermission(config) {
return withInfoPlist(config, (iosConfig) => {
// modResults is the parsed Info.plist dictionary
const plist = iosConfig.modResults;
// Add multiple keys at once
plist['NSUserTrackingUsageDescription'] =
'We use tracking to improve your ad experience.';
plist['ITSAppUsesNonExemptEncryption'] = false;
return iosConfig;
});
};Modifying AndroidManifest.xml
Use withAndroidManifest to modify AndroidManifest.xml. The modResults is an XML object parsed by the xmldom library. Expo's AndroidConfig helpers provide utility functions like addUsesPermission, addMetaDataItemToMainActivity, and getMainApplication that operate on this XML structure, so you rarely need to manipulate the XML DOM directly.
const { withAndroidManifest, AndroidConfig } = require('@expo/config-plugins');
const { addUsesPermission } = AndroidConfig.Permissions;
module.exports = function withBluetoothPermissions(config) {
return withAndroidManifest(config, (androidConfig) => {
const manifest = androidConfig.modResults;
// Add multiple permissions
addUsesPermission(manifest, 'android.permission.BLUETOOTH');
addUsesPermission(manifest, 'android.permission.BLUETOOTH_ADMIN');
addUsesPermission(manifest, 'android.permission.BLUETOOTH_SCAN');
return androidConfig;
});
};Testing Your Plugin with expo prebuild
Run npx expo prebuild --clean to test your plugin and inspect the generated native files. Use --clean to start fresh. After prebuild, open the generated files to verify your changes were applied. For plist changes check ios/YourApp/Info.plist. For manifest changes check android/app/src/main/AndroidManifest.xml. Fix any issues in the plugin file and re-run prebuild until the output is correct.
# Test the plugin
npx expo prebuild --clean --platform ios
# Verify the change in Info.plist
plutil -p ios/MyApp/Info.plist | grep -A1 Camera
# Output:
# "NSCameraUsageDescription" => "Scan QR codes."
# For Android
cat android/app/src/main/AndroidManifest.xml | grep BLUETOOTHAdding Files to the Native Project
Some plugins need to copy files into the native project — for example, a Google Services JSON file or a custom font. Use withDangerousMod for arbitrary filesystem operations during prebuild. This is the escape hatch for changes not covered by other helpers. Use it sparingly and document the side effects clearly.
const { withDangerousMod } = require('@expo/config-plugins');
const path = require('path');
const fs = require('fs');
module.exports = function withGoogleServicesJson(config) {
return withDangerousMod(config, [
'android',
async (dangerousConfig) => {
const projectRoot = dangerousConfig.modRequest.projectRoot;
const src = path.join(projectRoot, 'google-services.json');
const dest = path.join(projectRoot, 'android', 'app', 'google-services.json');
if (!fs.existsSync(src)) {
throw new Error('google-services.json not found in project root');
}
fs.copyFileSync(src, dest);
return dangerousConfig;
},
]);
};Accessing Config Values Inside a Plugin
The config object passed to your plugin contains the full Expo configuration, including values from app.json. You can read config.name, config.version, config.ios.bundleIdentifier, config.android.package, and any extra fields you defined. This lets your plugin be data-driven based on the app config rather than hard-coded values.
module.exports = function withAppMetadata(config) {
const appName = config.name;
const bundleId = config.ios?.bundleIdentifier;
const version = config.version;
return withInfoPlist(config, (iosConfig) => {
iosConfig.modResults['CFBundleDisplayName'] = appName;
// Log for debugging during prebuild
console.log(
'[withAppMetadata] Setting display name to:', appName,
'for bundle:', bundleId, 'v' + version
);
return iosConfig;
});
};Modifying build.gradle with withAppBuildGradle
To modify the Android app-level build.gradle file, use withAppBuildGradle. The modResults.contents property contains the file as a raw string, and you use string manipulation or regex to insert your changes. This approach is necessary for adding Gradle dependencies, plugin declarations, or custom build configurations that the higher-level helpers do not cover.
const { withAppBuildGradle } = require('@expo/config-plugins');
module.exports = function withGooglePlayServicesPlugin(config) {
return withAppBuildGradle(config, (gradleConfig) => {
const contents = gradleConfig.modResults.contents;
// Add google-services plugin if not already present
if (!contents.includes('com.google.gms.google-services')) {
gradleConfig.modResults.contents = contents.replace(
'apply plugin: "com.android.application"',
'apply plugin: "com.android.application"\napply plugin: "com.google.gms.google-services"'
);
}
return gradleConfig;
});
};Modifying the Xcode Project with withXcodeProject
For changes to the Xcode project file itself — like adding a build phase, linking a native library, or adding a capability — use withXcodeProject. The modResults is an XcodeProject object from the xcode npm package. This is the most complex mod and requires familiarity with the Xcode project format. Prefer higher-level helpers when they exist.
const { withXcodeProject } = require('@expo/config-plugins');
module.exports = function withRunScript(config) {
return withXcodeProject(config, (xcode) => {
const project = xcode.modResults;
const target = project.getFirstTarget().uuid;
// Add a run script build phase
project.addBuildPhase(
[],
'PBXShellScriptBuildPhase',
'Bundle React Native code and images',
target,
{ shellPath: '/bin/sh', shellScript: 'echo Running custom script' }
);
return xcode;
});
};Entitlements with withEntitlementsPlist
iOS entitlements are capability declarations in a .entitlements file that must match your provisioning profile. Use withEntitlementsPlist to add entitlements like Push Notifications, iCloud, HealthKit, or App Groups. Without the correct entitlements, features silently fail at runtime on device even if the code looks correct.
const { withEntitlementsPlist } = require('@expo/config-plugins');
module.exports = function withPushNotifications(config) {
return withEntitlementsPlist(config, (iosConfig) => {
const entitlements = iosConfig.modResults;
// Enable push notifications capability
entitlements['aps-environment'] = 'production';
// App Groups for sharing data with extensions
if (!entitlements['com.apple.security.application-groups']) {
entitlements['com.apple.security.application-groups'] = [];
}
entitlements['com.apple.security.application-groups'].push(
'group.com.yourapp.shared'
);
return iosConfig;
});
};Composing a Complete Plugin
Real-world plugins often need to modify multiple native files across both platforms. Compose helpers in sequence, passing the result of each into the next. Add clear input validation at the top and log progress messages so developers can trace what the plugin changed during prebuild. Return the final config after all modifications.
const {
withInfoPlist,
withAndroidManifest,
withEntitlementsPlist,
AndroidConfig,
} = require('@expo/config-plugins');
module.exports = function withPaymentSDK(config, opts = {}) {
if (!opts.merchantId) {
throw new Error('withPaymentSDK requires a merchantId option.');
}
// iOS
config = withInfoPlist(config, (c) => {
c.modResults['PaymentMerchantId'] = opts.merchantId;
return c;
});
config = withEntitlementsPlist(config, (c) => {
c.modResults['com.apple.developer.in-app-payments'] = [opts.merchantId];
return c;
});
// Android
config = withAndroidManifest(config, (c) => {
AndroidConfig.Permissions.addUsesPermission(
c.modResults, 'android.permission.INTERNET'
);
return c;
});
return config;
};Quick Check
Test your understanding of React Native Mobile Development concepts from this lesson.
Lesson Recap
In this lesson you learned: how to write a config plugin function that takes config and options and returns the modified config, how to use withInfoPlist, withAndroidManifest, withEntitlementsPlist, and withDangerousMod, and how to test your plugin with expo prebuild --clean. You also saw how to compose multiple mods into a complete cross-platform plugin. Next up we explore modifying AndroidManifest and Info.plist in detail.
Frequently asked questions
Is the “Writing Your First Config Plugin” lesson free?
Yes — the full text of “Writing Your First Config Plugin” is free to read here on the web, and the React Native Academy course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the React Native Academy course, upgrade to CoddyKit PRO.
What will I learn in “Writing Your First Config Plugin”?
Create a withMyPlugin function using the @expo/config-plugins helpers, add it to app.json's plugins array, and run expo prebuild to verify the native output. You practise React Native Academy with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.
Do I need any experience to start React Native Academy?
No prior experience is required. React Native Academy on CoddyKit is structured for beginners through advanced learners; this is — lesson 2 of 4, so you can start here or from the beginning and move at your own pace.
How long does the “Writing Your First Config Plugin” lesson take?
Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.
Can I write and run code in this React Native Academy lesson?
Yes. Every React Native Academy lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.
All lessons in this course
- What Are Config Plugins and When to Use Them
- Writing Your First Config Plugin
- Modifying AndroidManifest and Info.plist
- Distributing Config Plugins as npm Packages