How would you write an Expo config plugin that adds an iOS App Groups entitlement and an Android manifest meta-data entry idempotently?
answer
- one plugin per platform, composed
- withEntitlementsPlist edits a JS object
- application-groups is an array
- withAndroidManifest plus AndroidConfig.Manifest helpers
- check before adding: prebuild layers
basics
~10 sWrite two plugin functions and compose them: withEntitlementsPlist adds the group to the com.apple.security.application-groups array only if missing, and withAndroidManifest uses AndroidConfig.Manifest.addMetaDataItemToMainApplication, which updates an existing entry instead of duplicating it.
solid answer
~40 sI would write one plugin per platform and compose them with `withPlugins`. On iOS, `withEntitlementsPlist` gives me the app target's `.entitlements` file as a JS object in `config.modResults`, already merged with `ios.entitlements` from app config; I read `com.apple.security.application-groups`, which is an array, and append my group only if it is not there. On Android, `withAndroidManifest` gives me the manifest parsed by xml2js; I get the application element with `AndroidConfig.Manifest.getMainApplicationOrThrow` and call `addMetaDataItemToMainApplication`, which updates an entry with the same `android:name` instead of pushing a second one. Both checks matter because `npx expo prebuild` without `--clean` reads the files as they already are. Each callback returns `config`. On EAS Build the entitlement then drives capability sync with Apple.
code
typescript · 35 linesimport {
AndroidConfig,
ConfigPlugin,
withAndroidManifest,
withEntitlementsPlist,
withPlugins,
} from 'expo/config-plugins';
type Props = { appGroup: string };
const APP_GROUPS = 'com.apple.security.application-groups';
const withAppGroupEntitlement: ConfigPlugin<Props> = (config, { appGroup }) =>
withEntitlementsPlist(config, (config) => {
const groups = (config.modResults[APP_GROUPS] as string[] | undefined) ?? [];
if (!groups.includes(appGroup)) {
config.modResults[APP_GROUPS] = [...groups, appGroup];
}
return config;
});
const withAppGroupMetaData: ConfigPlugin<Props> = (config, { appGroup }) =>
withAndroidManifest(config, (config) => {
const app = AndroidConfig.Manifest.getMainApplicationOrThrow(config.modResults);
AndroidConfig.Manifest.addMetaDataItemToMainApplication(app, 'com.example.APP_GROUP', appGroup);
return config;
});
const withAppGroup: ConfigPlugin<Props> = (config, props) =>
withPlugins(config, [
[withAppGroupEntitlement, props],
[withAppGroupMetaData, props],
]);
export default withAppGroup;go deeper
Recall that entitlements and the Android manifest each have their own mod plugin, and that both hand you a JS object rather than raw file text.
Walk through both halves: the application-groups array on iOS, the xml2js shape and the AndroidConfig.Manifest helpers on Android, and why each callback returns config.
Make idempotency explicit: prebuild without --clean rereads your own output, so check before adding, update in place, and verify by running prebuild twice.
Decide when a plugin is warranted at all: ios.entitlements and the build-time manifest merge cover static cases, and every custom plugin is code the team re-tests each SDK.
## The task A common reason to write a config plugin is a feature that needs native setup on both platforms. Suppose an app ships a home-screen widget that must read data the app writes. On iOS the app and the widget share storage through an **App Group**, which is an entitlement on the app target. On Android, a native SDK in the project reads the same group identifier from a `<meta-data>` element inside `<application>` in `AndroidManifest.xml`. The goal is a plugin that sets both from one prop and can be run any number of times. If the entitlement were static, `ios.entitlements` in app config could set it without any plugin. A plugin earns its place when a library must apply the setting for its users, or when the value is computed from props. ## Structure: one plugin function per platform Keep the platform halves separate and compose them: 1. `withAppGroupEntitlement` uses `withEntitlementsPlist`. 2. `withAppGroupMetaData` uses `withAndroidManifest`. 3. `withAppGroup` applies both with `withPlugins(config, [[a, props], [b, props]])`, which applies the list in order. Splitting them keeps each mod small, lets each be unit-tested against a fixture object, and makes debug output name the half that failed. ## The iOS half: withEntitlementsPlist `withEntitlementsPlist` registers a mod on `mods.ios.entitlements`. When prebuild runs it: - locates the app target's `.entitlements` file, creating one from a template if needed; - parses it into a JS object and merges in `ios.entitlements` from app config; - passes that object to your callback as `config.modResults`; - after your callback, copies `modResults` back into `config.ios.entitlements` and writes the plist. The App Groups entitlement key is `com.apple.security.application-groups`, and its value is an **array of group identifiers**. So the callback reads the existing array (or an empty one), adds the group only if it is absent, and assigns it back. Overwriting the array would silently drop a group another plugin or the app config already added; blindly pushing would duplicate it. Because the entitlements mod supports **introspection**, EAS Build can read the final entitlements without generating the project and then synchronize the matching capabilities on the Apple Developer Console before building. App Groups is one of the capabilities it supports. ## The Android half: withAndroidManifest `withAndroidManifest` hands you the manifest parsed by xml2js: elements become arrays of objects, and attributes live under a `$` key, so `<application>` is `modResults.manifest.application[0]`. You can walk that shape by hand, but `expo/config-plugins` exports helpers under `AndroidConfig.Manifest` that encode the edge cases: | Helper | What it does | |---|---| | `getMainApplicationOrThrow(manifest)` | Returns the `<application>` element, throwing a clear error if the template lacks one | | `addMetaDataItemToMainApplication(app, name, value)` | Updates the `<meta-data>` with that `android:name`, or adds it if absent | | `removeMetaDataItemFromMainApplication(app, name)` | Removes it, useful when a prop is turned off | | `AndroidConfig.Permissions.ensurePermission(manifest, name)` | Adds a `<uses-permission>` only if missing | The value of the helper is **idempotency**: pushing a new object onto `mainApplication['meta-data']` works on the first run and duplicates the element on every later one. ## Why idempotency is the whole point `npx expo prebuild --clean` deletes the native folders and starts from the template, so a careless plugin looks fine there. Plain `npx expo prebuild` **layers onto existing files**: the base mods read the current `Info.plist`, entitlements and manifest from disk, including what your plugin wrote last time. A correct plugin therefore has to: - check before adding (arrays, `<meta-data>`, permissions); - update in place rather than append when a value changes; - never assume it is the only writer of a shared array. Standard mods such as these can run repeatedly with the same result when written this way; that is the property that lets teams run prebuild without `--clean` during development. ## Common mistakes - Reaching for `withDangerousMod` because `AndroidManifest.xml` is XML: the typed manifest mod already parses it and survives template changes far better than a regex. - Forgetting to **return `config`** from one of the callbacks, which breaks the mod chain with an error naming the mod. - Adding platform checks by hand: a mod registered on the iOS side only runs when iOS is prebuilt, so it needs no check of `modRequest.platform`. - Hard-coding the group identifier instead of taking it as a prop, which forces a code change per app or per environment. ## Checking the result - `npx expo prebuild --no-install` and inspect the generated files. - `npx expo config --type introspect` shows the evaluated entitlements and manifest under `_internal.modResults` without writing anything. - Run prebuild twice in a row and diff: the second run must change nothing.
- Why read the existing application-groups array instead of assigning a one-element array in an Expo entitlements mod?The entitlements `modResults` already holds what the file, `ios.entitlements` and earlier plugins contributed. Assigning a fresh array would erase another group that one of them added. Reading, checking and appending keeps every contributor's value and keeps the result stable across repeated prebuilds.
- How can you confirm what an Expo plugin's entitlements mod will produce without generating the ios folder?Run `npx expo config --type introspect`. It evaluates introspectable mods such as the entitlements, Info.plist and manifest mods in memory and prints their results under `_internal.modResults`, writing nothing to disk. EAS Build uses the same introspected entitlements to decide which capabilities to sync.
saying these in an interview costs you the question
- Pushing into modResults is safe because prebuild always starts from the template.
- withEntitlementsPlist hands you the raw plist XML to edit with a regex.
- Editing AndroidManifest.xml needs withDangerousMod because it is an XML file.
- Assigning a one-element application-groups array is fine; nothing else writes it.
- A static entitlement always requires a custom plugin rather than ios.entitlements.