Plugin development for libraries
Edit this page
Learn how to develop config plugins for Expo and React Native libraries.
Expo config plugins in a React Native library represent a transformative approach to automating native project configuration. Rather than requiring library users to manually edit native files, such as AndroidManifest.xml, Info.plist, and so on, you can provide a plugin that handles these configurations automatically during the prebuild process. This changes developer experience from error-prone manual setup to reliable, automated configuration that can work consistently across different projects.
This guide explains key configuration steps and strategies that you can use to implement a config plugin in your library.
Strategic value of a config plugin in a library
Config plugins tend to solve interconnected problems that have historically made React Native library adoption more difficult than it should be. At times, when a user installs a React Native library, they face a complex set of native configuration steps that must be performed correctly for the library to function. These steps are platform-specific and sometimes require deep knowledge of native development concepts.
By creating a config plugin within your library, you can transform this complex-looking manual process into a simple configuration declaration that a user can apply in their Expo project's app config file (usually, app.json). This reduces the barrier to adoption for your library and simultaneously makes the setup process reliable.
Beyond immediate user experience improvements, config plugins enable compatibility with Continuous Native Generation, where native directories are generated automatically rather than checked into version control. Without a config plugin, developers who have adopted CNG face a difficult choice: either abandon the CNG workflow to manually configure native files, or invest significant effort in creating their own automation solutions. This creates a substantial barrier to library adoption in modern Expo development workflows.
Project structure
A directory structure is the foundation for maintaining config plugins within your library. Below is an example directory structure:
.
android
Android native module code
src
main
java
com
your-awesome-library
build.gradle
ios
iOS native module code
YourAwesomeLibrary
YourAwesomeLibrary.podspec
src
index.ts
Main library entry point
YourAwesomeLibrary.ts
Core library implementation
types.ts
TypeScript type definitions
plugin
src
index.ts
Plugin entry point
withAndroid.ts
Android-specific configurations
withIos.ts
iOS-specific configurations
build
__tests__
tsconfig.json
Plugin-specific TypeScript config
example
app.json
Example app configuration
App.tsx
Example app implementation
package.json
Example app dependencies
__tests__
app.plugin.js
Plugin entry point for Expo CLI
package.json
Package configuration
tsconfig.json
Main TypeScript configuration
jest.config.js
Testing configuration
README.md
Documentation
The directory structure example above highlights the following organizational principles:
- Root-level separation: Clear boundaries between library code (src) and plugin implementation (plugin)
- Plugin directory organization: Platform-specific files (withAndroid.ts, withIos.ts) enable focused testing and maintenance
- Build output management: Compiled JavaScript and TypeScript declarations in plugins/build/ directory
- Testing: Separate plugin tests from library tests to reflect different concerns.
Installation and configuration for development
The most straightforward approach to leverage Expo's tooling is to use expo
and expo-module-scripts
.
expo
provides a config plugin API and types that your plugin will use.expo-module-scripts
provides build tooling specifically designed for Expo modules and config plugins. It also handles TypeScript compilation.
-
npx expo install package
When using expo-module-scripts
, it requires the following package.json configuration. For any already existing script with the same script name, replace it.
{ "scripts": { "build": "expo-module build", "build:plugin": "expo-module build plugin", "clean": "expo-module clean", "test": "expo-module test", "prepare": "expo-module prepare", "prepublishOnly": "expo-module prepublishOnly" }, "devDependencies": { "expo": "^53.0.0" }, "peerDependencies": { "expo": ">=53.0.0" }, "peerDependenciesMeta": { "expo": { "optional": true } } }
The next step is to add TypeScript support within the plugins directory. Open plugins/tsconfig.json file and add the following:
{ "extends": "expo-module-scripts/tsconfig.plugin", "compilerOptions": { "outDir": "build", "rootDir": "src" }, "include": ["./src"], "exclude": ["**/__mocks__/*", "**/__tests__/*"] }
You also need to define the main entry point for your config plugin in the app.plugin.js file, which exports the compiled plugin code from the plugin/build directory:
module.exports = require('./plugin/build');
The above configuration is essential because when the Expo CLI looks for a plugin, it checks for this file in the project root of your library. The plugin/build directory contains the JavaScript files generated from your config plugin's TypeScript source code.
Key implementation patterns
Essential patterns for a successful config plugin implementation include:
- Plugin structure: Core patterns that every plugin should follow
- Platform-specific implementations: Handle Android and iOS configurations effectively
- Test strategies: Validating your plugin code through testing
Plugin structure and platform-specific implementation
Every config plugin follows the same pattern: receives configuration and parameters, applies transformations through mods, and returns the modified configuration. Consider the following core plugin structure looks like:
import { type ConfigPlugin, withAndroidManifest, withInfoPlist } from 'expo/config-plugins'; export interface YourLibraryPluginProps { customProperty?: string; enableFeature?: boolean; } const withYourLibrary: ConfigPlugin<YourLibraryPluginProps> = (config, props = {}) => { // Apply Android configurations config = withAndroidConfiguration(config, props); // Apply iOS configurations config = withIosConfiguration(config, props); return config; }; export default withYourLibrary;
import { type ConfigPlugin, withAndroidManifest, AndroidConfig } from 'expo/config-plugins'; export const withAndroidConfiguration: ConfigPlugin<YourLibraryPluginProps> = (config, props) => { return withAndroidManifest(config, config => { const mainApplication = AndroidConfig.Manifest.getMainApplicationOrThrow(config.modResults); AndroidConfig.Manifest.addMetaDataItemToMainApplication( mainApplication, 'your_library_config_key', props.customProperty || 'default_value' ); return config; }); };
import { type ConfigPlugin, withInfoPlist } from 'expo/config-plugins'; export const withIosConfiguration: ConfigPlugin<YourLibraryPluginProps> = (config, props) => { return withInfoPlist(config, config => { config.modResults.YourLibraryCustomProperty = props.customProperty || 'default_value'; if (props.enableFeature) { config.modResults.YourLibraryFeatureEnabled = true; } return config; }); };
Testing strategies
Config plugin testing differs from regular library testing because you are testing configuration transformations rather than runtime behavior. Your plugin receives configuration objects and returns modified configuration objects.
Effective testing for a config plugin can be a combination of one or more of the following:
- Unit testing: Test configuration transformation logic with mocked Expo configuration objects
- Cross-platform validation: Use an example app to verify the actual prebuild output
- Error condition testing: Use error handling
Since unit tests focus on a plugin's transformation logic without involving the file system, you can use Jest to create and run mock configuration objects, pass them through your plugin, and verify expected modifications are made correctly. For example:
import { withYourLibrary } from '../src'; describe('withYourLibrary', () => { it('should configure Android with custom property', () => { const config = { name: 'test-app', slug: 'test-app', platforms: ['android', 'ios'], }; const result = withYourLibrary(config, { customProperty: 'test-value', }); // Verify the plugin was applied correctly expect(result.plugins).toBeDefined(); }); });
Errors should be handled gracefully inside your config plugin to provide clear feedback when a configuration fails. Use try-catch
blocks to intercept errors early:
const withYourLibrary: ConfigPlugin<YourLibraryPluginProps> = (config, props = {}) => { try { // Validate configuration early validateProps(props); // Apply configurations config = withAndroidConfiguration(config, props); config = withIosConfiguration(config, props); return config; } catch (error) { // Re-throw with more context if needed throw new Error(`Failed to configure YourLibrary plugin: ${error.message}`); } };
Alternative build approaches
If your library doesn't use expo-module-scripts
, you have two options:
Add a plugin to your main package
For libraries using different build tools (like those created with create-react-native-library
), add an app.plugin.js file and build it along with your main package:
module.exports = require('./lib/plugin');
Create a separate plugin package
Some libraries distribute their config plugin as a separate package from their main library. This approach allows you to maintain your config plugin separately from the rest of your native module. You need to include export in app.plugin.js and compile the build directory from your plugin.
{ "name": "your-library-expo-plugin", "main": "app.plugin.js", "files": ["app.plugin.js", "build/"], "peerDependencies": { "expo": "*", "your-library": "*" } }
Plugin development best practices
- Instructions in your README: If the plugin is tied to a React Native module, then you should document manual setup instructions for the package. If anything goes wrong with the plugin, developers should be able to manually add the project modifications that were automated by the plugin. This also allows you to support projects that are not using CNG.
- Document the available properties for the plugin, specifying if any of the properties are required.
- If possible, plugins should be idempotent, meaning the changes they make are the same whether they are run on a fresh native project template or run again on a project template where its changes already exist. This allows developers to run
npx expo prebuild
without the--clean
flag to sync changes to the config, rather than recreating the native project entirely. This may be more difficult with dangerous mods.
- Naming conventions: Use
withFeatureName
for the plugin function name if it applies to all platforms. If the plugin is platform-specific, use a camel case naming with the platform right after "with". For example,withAndroidSplash
,withIosSplash
. - Leverage built-in plugins: If there's already a configuration available in app config and prebuild config, you don't need to write a config plugin for it.
- Split up plugins by platform: When using functions within the config plugin, split them by platform. For example,
withAndroidSplash
,withIosSplash
. This makes using the--platform
flag innpx expo prebuild
a bit easier to follow inEXPO_DEBUG
mode, as the logging will show which platform-specific functions are being executed. - Unit test your plugin: Write Jest tests for complex modifications. If your plugin requires access to the filesystem,
use a mock system (we strongly recommend
memfs
), you can see examples of this in theexpo-notifications
plugin tests.- Notice the root **/__mocks__/**/* directory and plugin/jest.config.js.
- A TypeScript plugin is always preferable to a JavaScript due to added type-safety. Check out the
expo-module-scripts
plugin tooling for more info. - Do not modify the
sdkVersion
via a config plugin, this can break commands likeexpo install
and cause other unexpected issues.