Nearly every stuck React Native upgrade we get called into looks the same. The app is two or three minor versions behind, the team has budgeted a week, and three days in someone discovers that ios/Podfile has a hand-written post_install hook, AndroidManifest.xml has a <queries> block added in 2023, and the iOS project has a build phase nobody can explain. None of it is documented. All of it has to survive the upgrade.
That is the real cost of treating ios/ and android/ as source code. Continuous native generation (CNG) is the alternative: the native projects are build output, generated from your app config plus a set of config plugins, and regenerated from scratch whenever anything changes. This post is how we migrate real client apps to it.
What CNG actually means
Under CNG, npx expo prebuild --clean deletes ios/ and android/ and regenerates them from:
- your
app.json/app.config.ts - the native code shipped inside your dependencies (autolinking)
- config plugins — small JS functions that mutate the generated native project
The native folders go in .gitignore. Nobody edits them, because any edit is erased on the next prebuild. That constraint sounds severe and is the entire benefit: it forces every native customisation to become a reviewable, re-appliable piece of code.
The payoff shows up at upgrade time. Upgrading React Native stops being a three-way merge against templates and becomes: bump versions, run prebuild, fix the handful of plugins that broke.
Is your app a candidate?
CNG is a good fit when your native customisations are configuration-shaped: permission strings, URL schemes, entitlements, Gradle properties, extra Info.plist keys, SDK initialisation snippets.
It is a poor fit, or at least a bigger project, when:
- you have brownfield integration — React Native embedded in an existing native app, or native screens in your RN app that live in
ios/ - you maintain in-repo native modules with substantial Objective-C/Kotlin source (these can be moved to a local package, but that is its own migration)
- you ship custom Xcode targets: widgets, App Clips, watch apps, share extensions. Plugins can create these, but it is advanced work and you will be writing generator code.
Our usual triage: run npx expo prebuild --clean on a throwaway branch and diff the result against the committed folders. That diff is your migration backlog, and it is usually far shorter than people fear.
Step one: inventory the diff
git switch -c cng-spike
cp -R ios ios.bak && cp -R android android.bak
npx expo prebuild --clean
diff -ru ios.bak/YourApp.xcodeproj ios/YourApp.xcodeproj | head -200
diff -ru android.bak/app/src/main android/app/src/main
Sort every difference into three buckets:
- Expressible in app config. Bundle identifier, version, icons, splash screen, permission usage descriptions, URL schemes,
associatedDomains, AndroidusesCleartextTraffic. Move these intoapp.config.tsand delete the manual edit. - Provided by a library's own plugin. Most native libraries now ship one; you just add it to
pluginsand pass options. Check the library docs before writing anything yourself. - Genuinely custom. This is what you write plugins for — and it is usually two to five items.
Step two: write a config plugin
A config plugin is a function that takes the Expo config and returns a modified one, usually by registering a mod that runs during prebuild against a specific native file. Here is one that adds a Gradle property and an Info.plist key:
// plugins/withOurNativeTweaks.js
const { withGradleProperties, withInfoPlist } = require('expo/config-plugins');
const withGradleFlag = (config) =>
withGradleProperties(config, (cfg) => {
cfg.modResults = cfg.modResults.filter(
(item) => !(item.type === 'property' && item.key === 'org.gradle.jvmargs')
);
cfg.modResults.push({
type: 'property',
key: 'org.gradle.jvmargs',
value: '-Xmx4g -XX:MaxMetaspaceSize=1g',
});
return cfg;
});
const withAppTransportException = (config, { domain }) =>
withInfoPlist(config, (cfg) => {
cfg.modResults.NSAppTransportSecurity = {
NSExceptionDomains: {
[domain]: { NSExceptionAllowsInsecureHTTPLoads: true },
},
};
return cfg;
});
module.exports = function withOurNativeTweaks(config, props = {}) {
config = withGradleFlag(config);
if (props.insecureDomain) {
config = withAppTransportException(config, { domain: props.insecureDomain });
}
return config;
};
Wire it up in app.config.ts:
export default {
expo: {
name: 'Our App',
slug: 'our-app',
ios: { bundleIdentifier: 'com.example.ourapp' },
android: { package: 'com.example.ourapp' },
plugins: [
'expo-router',
['./plugins/withOurNativeTweaks', { insecureDomain: 'legacy.internal' }],
],
},
};
Three rules we hold plugin code to on client projects:
- Idempotent. A plugin may run against an already-modified file. Filter-then-append, as above; never blindly push a duplicate entry.
- Anchored, not line-numbered. When you must patch source (
AppDelegate,MainApplication), match on a distinctive string and fail loudly if it is missing.expo/config-pluginsshipsmergeContentswith a tagged-block mechanism that handles the common cases. - Documented at the top. One comment explaining why this native change exists. That comment is the artefact your successor needed and did not have.
Step three: make CI the enforcer
CNG rots quietly. Someone runs a native build locally, Xcode "helpfully" fixes a setting, and it works on their machine forever. Two guards prevent this.
First, ignore the folders and never resurrect them:
/ios
/android
Second, add a CI job that regenerates and fails on drift:
- run: npx expo prebuild --clean --no-install
- run: |
if [ -n "$(git status --porcelain)" ]; then
echo 'prebuild produced changes not represented in config — fix your plugins'
git status --porcelain
exit 1
fi
Also track the fingerprint. npx expo-updates fingerprint:generate hashes the native inputs, so a changed fingerprint on a pull request is a reliable signal that a new binary is required and that an OTA update will not be enough. We wire that into PR checks so nobody discovers it after a release.
Step four: keep local dev usable
Teams sometimes report that CNG made day-to-day work worse. Usually three habits fix it:
- Add
"prebuild": "expo prebuild --clean"and apostinstallthat runs it, so a fresh clone plusnpm installyields buildable native projects with no tribal knowledge. - Use a development build rather than Expo Go the moment you have any custom native code, so the JS you run locally matches the native surface you actually ship.
- Do not run prebuild on every branch switch. Run it when
package.json, the app config, orplugins/changed — a cheap hash check in your dev script covers it.
The honest trade-off
CNG does not remove native complexity; it relocates it into JavaScript you own and review. You trade "open Xcode and click the thing" for "write a plugin, test it, commit it." That is slower the first time and dramatically faster every time afterwards — most visibly on SDK upgrades, on onboarding, and when reproducing a build that shipped six months ago.
The apps we see suffer most are the ones stuck in between: native folders committed and a growing set of config plugins, with no rule about which one wins. Pick a side. If you are greenfield, be CNG from day one. If you are migrating, spend the two days on the prebuild diff — it is the cheapest React Native upgrade insurance available.
If you would like a second set of eyes on that diff, or a plugin written for a native SDK that does not ship one, get in touch. Reviewing native configuration and unblocking stalled upgrades is a large share of what our React Native consultants do.