Tutorial: Create a module with a config plugin

Edit this page

A tutorial on creating a native module with a config plugin using Expo modules API.


Config plugins allow you to customize native Android and iOS projects when they are generated with npx expo prebuild. It is often useful to add properties in native config files, to copy assets to native projects, and for advanced configurations such as adding an app extension target. As an app developer, applying customizations not exposed in the default app config can be helpful. As a library author, it allows you to configure native projects for the developers using your library automatically.

This guide will walk you through creating a new config plugin from scratch and show you how to read custom values injected into AndroidManifest.xml and Info.plist by your plugin from an Expo module.

1. Initialize a module

Start by initializing a new Expo module project using create-expo-module, which will provide scaffolding for Android, iOS, and TypeScript. It will also provide an example project to interact with the module from within an app. Run the following command to initialize it:

Terminal
npx create-expo-module expo-native-configuration

We will use the name expo-native-configuration/ExpoNativeConfiguration for the project. You can name it whatever you like.

2. Set up our workspace

In our example, we won't need the view module included by create-expo-module. Let's clean up the default module a little bit with the following command:

Terminal
cd expo-native-configuration
rm ios/ExpoNativeConfigurationView.swift
rm android/src/main/java/expo/modules/nativeconfiguration/ExpoNativeConfigurationView.kt
rm src/ExpoNativeConfigurationView.tsx src/ExpoNativeConfiguration.types.ts
rm src/ExpoNativeConfigurationView.web.tsx src/ExpoNativeConfigurationModule.web.ts

We also need to find ExpoNativeConfigurationModule.swift, ExpoNativeConfigurationModule.kt, src/index.ts and example/App.tsx and replace them with the provided minimal boilerplate:

ios/ExpoNativeConfigurationModule.swift
import ExpoModulesCore

public class ExpoNativeConfigurationModule: Module {
  public func definition() -> ModuleDefinition {
    Name("ExpoNativeConfiguration")

    Function("getApiKey") { () -> String in
      "api-key"
    }
  }
}
android/src/main/java/expo/modules/nativeconfiguration/ExpoNativeConfigurationModule.kt
package expo.modules.nativeconfiguration

import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition

class ExpoNativeConfigurationModule : Module() {
  override fun definition() = ModuleDefinition {
    Name("ExpoNativeConfiguration")

    Function("getApiKey") {
      return@Function "api-key"
    }
  }
}
src/index.ts
import ExpoNativeConfigurationModule from './ExpoNativeConfigurationModule';

export function getApiKey(): string {
  return ExpoNativeConfigurationModule.getApiKey();
}
example/App.tsx
import * as ExpoNativeConfiguration from 'expo-native-configuration';
import { Text, View } from 'react-native';

export default function App() {
  return (
    <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
      <Text>API key: {ExpoNativeConfiguration.getApiKey()}</Text>
    </View>
  );
}

3. Run the example project

Now let's run the example project to make sure everything is working. Start the TypeScript compiler to watch for changes and rebuild the module JavaScript.

Terminal
# Run this in the root of the project to start the TypeScript compiler
npm run build

In another terminal window, compile and run the example app:

Terminal
cd example
# Run the example app on iOS
npx expo run:ios
# Run the example app on Android
npx expo run:android

We should see a screen with a text saying API key: api-key. Now let's develop the plugin to inject our custom API key.

4. Creating a new config plugin

Let's start developing our new config plugin. Plugins are synchronous functions that accept an ExpoConfig and return a modified ExpoConfig. By convention, these functions are prefixed by the word with. We will name our plugin withMyApiKey. Feel free to call it whatever you like, as long as it follows the convention.

Here is an example of how a basic config plugin function looks:

const withMyApiKey = config => {
  return config;
};

Additionally, you can use mods, which are async functions that modify files in native projects such as source code or configuration (plist, xml) files. The mods object is different from the rest of the app config because it doesn't get serialized after the initial reading. This means you can use it to perform actions during code generation.

However, there are a few considerations that we should follow when writing config plugins:

  • Plugins should be synchronous and their return value should be serializable, except for any mods that are added.
  • plugins are always invoked when the config is read by the expo/config method getConfig. However, mods are only invoked during the "syncing" phase of npx expo prebuild.

Although not required, we can use expo-module-scripts to make plugin development easier — it provides a recommended default configuration for TypeScript and Jest. For more information, see config plugins guide.

Let's start by creating our plugin with this minimal boilerplate. This will create a plugin folder where we will write the plugin in TypeScript and add an app.plugin.js file in the project root, which will be the entry file for the plugin.

  1. Create a plugin/tsconfig.json file:

    plugin/tsconfig.json
    {
      "extends": "expo-module-scripts/tsconfig.plugin",
      "compilerOptions": {
        "outDir": "build",
        "rootDir": "src"
      },
      "include": ["./src"],
      "exclude": ["**/__mocks__/*", "**/__tests__/*"]
    }
    
  2. Create a plugin/src/index.ts file for our plugin:

    plugin/src/index.ts
    import { ConfigPlugin } from 'expo/config-plugins';
    
    const withMyApiKey: ConfigPlugin = config => {
      console.log('my custom plugin');
      return config;
    };
    
    export default withMyApiKey;
    
  3. Finally, create an app.plugin.js file in the root directory. That will configure the entry file for our plugin:

    app.plugin.js
    module.exports = require('./plugin/build');
    

At the root of your project, run npm run build plugin to start the TypeScript compiler in watch mode. The only thing left to configure is our example project to use our plugin. We can achieve this by adding the following line to the example/app.json file.

example/app.json
{
  "expo": {
    ...
    "plugins": ["../app.plugin.js"]
  }
}

Now when running npx expo prebuild inside our example folder we should see our 'my custom plugin' console.log statement in the terminal.

Terminal
cd example
npx expo prebuild --clean

To inject our custom API keys into AndroidManifest.xml and Info.plist we can use a few helper mods provided by expo/config-plugins, which makes it easy to modify native files. In our example, we will use two of them, withAndroidManifest and withInfoPlist.

As the name suggests, withInfoPlist allows us to read and modify Info.plist values. Using the modResults property, we can add custom values as demonstrated in the code snippet below:

const withMyApiKey: ConfigPlugin<{ apiKey: string }> = (config, { apiKey }) => {
  config = withInfoPlist(config, config => {
    config.modResults['MY_CUSTOM_API_KEY'] = apiKey;
    return config;
  });

  return config;
};

Similarly, we can use withAndroidManifest to modify the AndroidManifest.xml file. In this case, we will utilize AndroidConfig helpers to add a metadata item to the main application:

const withMyApiKey: ConfigPlugin<{ apiKey: string }> = (config, { apiKey }) => {
  config = withAndroidManifest(config, config => {
    const mainApplication = AndroidConfig.Manifest.getMainApplicationOrThrow(config.modResults);

    AndroidConfig.Manifest.addMetaDataItemToMainApplication(
      mainApplication,
      'MY_CUSTOM_API_KEY',
      apiKey
    );
    return config;
  });

  return config;
};

We can create our custom plugin by merging everything into a single function:

plugin/src/index.ts
import {
  withInfoPlist,
  withAndroidManifest,
  AndroidConfig,
  ConfigPlugin,
} from 'expo/config-plugins';

const withMyApiKey: ConfigPlugin<{ apiKey: string }> = (config, { apiKey }) => {
  config = withInfoPlist(config, config => {
    config.modResults['MY_CUSTOM_API_KEY'] = apiKey;
    return config;
  });

  config = withAndroidManifest(config, config => {
    const mainApplication = AndroidConfig.Manifest.getMainApplicationOrThrow(config.modResults);

    AndroidConfig.Manifest.addMetaDataItemToMainApplication(
      mainApplication,
      'MY_CUSTOM_API_KEY',
      apiKey
    );
    return config;
  });

  return config;
};

export default withMyApiKey;

Now with the plugin ready to be used, let's update the example app to pass our API key to the plugin as a configuration option. Modify the "plugins" field in example/app.json as shown below:

example/app.json
{
  "expo": {
    ...
    "plugins": [["../app.plugin.js", { "apiKey": "custom_secret_api" }]]
  }
}

After making this change, we can test that the plugin is working correctly by running the command npx expo prebuild --clean inside the example folder. This will execute our plugin and update native files, injecting "MY_CUSTOM_API_KEY" into AndroidManifest.xml and Info.plist. You can verify this by checking the contents of example/android/app/src/main/AndroidManifest.xml.

5. Reading native values from the module

Now let's make our native module read the fields we added to AndroidManifest.xml and Info.plist. This can be done by using platform-specific methods to access the contents of these files.

On iOS, we can read the content of an Info.plist property by using the Bundle.main.object(forInfoDictionaryKey: "") instance Method. To read the "MY_CUSTOM_API_KEY" value that we added earlier, update the ios/ExpoNativeConfigurationModule.swift file:

ios/ExpoNativeConfigurationModule.swift
import ExpoModulesCore

public class ExpoNativeConfigurationModule: Module {
  public func definition() -> ModuleDefinition {
    Name("ExpoNativeConfiguration")

    Function("getApiKey") {
     return Bundle.main.object(forInfoDictionaryKey: "MY_CUSTOM_API_KEY") as? String
    }
  }
}

On Android, we can access metadata information from the AndroidManifest.xml file using the packageManager class. To read the "MY_CUSTOM_API_KEY" value, update the android/src/main/java/expo/modules/nativeconfiguration/ExpoNativeConfigurationModule.kt file:

android/src/main/java/expo/modules/nativeconfiguration/ExpoNativeConfigurationModule.kt
package expo.modules.nativeconfiguration

import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import android.content.pm.PackageManager

class ExpoNativeConfigurationModule() : Module() {
  override fun definition() = ModuleDefinition {
    Name("ExpoNativeConfiguration")

    Function("getApiKey") {
      val applicationInfo = appContext?.reactContext?.packageManager?.getApplicationInfo(appContext?.reactContext?.packageName.toString(), PackageManager.GET_META_DATA)

      return@Function applicationInfo?.metaData?.getString("MY_CUSTOM_API_KEY")
    }
  }
}

6. Running your module

With our native modules reading the fields we added to the native files, we can now run the example app and access our custom API key through the ExamplePlugin.getApiKey() function.

Terminal
cd example
# execute our plugin and update native files
npx expo prebuild
# Run the example app on iOS
npx expo run:ios
# Run the example app on Android
npx expo run:android

Next steps

Congratulations, you have created a simple yet non-trivial config plugin that interacts with an Expo module for Android and iOS!

If you want to challenge yourself and make the plugin more versatile we leave this exercise open to you. Try modifying the plugin to allow for any arbitrary set of config keys/values to be passed in and adding the functionality to allow for the reading of arbitrary keys from the module.