An Expo config plugin that writes arbitrary keys into the generated android/gradle.properties.
It is the generic escape hatch that expo-build-properties doesn't have.
// app.config.ts
plugins: [
['expo-gradle-properties', {
'org.gradle.jvmargs': '-Xmx4g -XX:MaxMetaspaceSize=2g',
'kotlin.daemon.jvmargs': '-Xmx2g',
'org.gradle.parallel': true,
}],
]Contents — Why · Install · Usage · What you can set · Android ABIs · Behaviour · Override warning · Validation · vs expo-build-properties
In a prebuild (CNG) Expo app, android/ is a build artifact. Editing android/gradle.properties by hand works exactly until the next expo prebuild throws it away, so the only durable way to set those values is a config plugin.
expo-build-properties is the obvious place for this, and it can't do it. As of v57.0.13 its entire Android surface is:
minSdkVersion compileSdkVersion targetSdkVersion buildToolsVersion cmakeVersion
kotlinVersion enableMinifyInReleaseBuilds enableShrinkResourcesInReleaseBuilds
enablePngCrunchInReleaseBuilds extraProguardRules packagingOptions networkInspector
extraMavenRepos usesCleartextTraffic useLegacyPackaging manifestQueries
useDayNightTheme enableBundleCompression buildArchs exclusiveMavenMirror
Exactly one of those reaches gradle.properties — buildArchs, written out as reactNativeArchitectures. There is no way to express org.gradle.jvmargs, kotlin.daemon.jvmargs, org.gradle.caching, or any flag your own Gradle code reads.
And the failure is a quiet one. Passing an unknown key to expo-build-properties is not an error and not a warning — the value is accepted, dropped, and the build carries on with the template defaults. You find out fourteen minutes into a release build, via an OutOfMemoryError: Metaspace that points at nothing.
So this plugin has one job, and it is loud about doing it.
npx expo install expo-gradle-propertiesPass a flat map. This is the common case.
plugins: [
['expo-gradle-properties', {
'org.gradle.jvmargs': '-Xmx4g -XX:MaxMetaspaceSize=2g -Dfile.encoding=UTF-8',
'kotlin.daemon.jvmargs': '-Xmx2g',
'org.gradle.parallel': true,
'org.gradle.caching': true,
reactNativeArchitectures: 'arm64-v8a',
}],
]Pass properties plus options.
plugins: [
['expo-gradle-properties', {
properties: {
'org.gradle.jvmargs': '-Xmx4g',
'org.gradle.configureondemand': null, // remove the key entirely
},
comment: 'set by our build config',
warnOnUserOverride: true,
}],
]The full form is used when the config has a properties key holding an object. (A Gradle property genuinely named properties therefore needs the full form: { properties: { properties: '…' } }.)
The plugin is not run-once, so you can list it several times to group unrelated settings. Every entry applies.
Set any given key in one entry only. If two entries set the same key, the winner is decided by Expo's mod ordering rather than by the plugin — and that ordering is the reverse of what you would expect, so the earlier entry wins. Don't build on that; keep each key in one place.
plugins: [
// memory, with a reason attached
['expo-gradle-properties', { 'org.gradle.jvmargs': '-Xmx4g' }],
// ...and CI-only caching, kept separate
['expo-gradle-properties', {
properties: { 'org.gradle.caching': process.env.CI ? true : null },
comment: 'CI overrides',
}],
]| Option | Default | Meaning |
|---|---|---|
properties |
— | The map of properties to write. Required in the full form. |
comment |
'Managed by expo-gradle-properties' |
Header written above appended keys. false writes none. |
warnOnUserOverride |
true |
Warn when $GRADLE_USER_HOME/gradle.properties will beat these values. See below. |
| You write | The file gets |
|---|---|
'-Xmx4g' |
-Xmx4g |
4096 |
4096 |
true / false |
true / false |
null / undefined |
the key is removed |
null lets you drop a key the Expo template sets, and makes conditionals read cleanly:
'org.gradle.caching': process.env.CI ? true : null,Any key at all. The plugin has no list of supported properties — it writes whatever you give it, which is the whole point. These are simply the ones people reach for most often.
| Category | Properties |
|---|---|
| Build speed | org.gradle.caching · org.gradle.parallel · org.gradle.configureondemand · org.gradle.workers.max · org.gradle.daemon |
| Memory | org.gradle.jvmargs · kotlin.daemon.jvmargs |
| Architectures | reactNativeArchitectures — see below |
| React Native / Expo | newArchEnabled · hermesEnabled · expo.useLegacyPackaging · expo.gif.enabled · expo.webp.enabled · expo.webp.animated |
| AndroidX | android.useAndroidX · android.enableJetifier · android.nonTransitiveRClass |
| Your own | any flag your build.gradle reads via project.findProperty('myFlag') |
Three things worth knowing before you reach for these.
The Expo template already sets several of them — newArchEnabled, hermesEnabled, reactNativeArchitectures and the expo.* image flags all ship in the generated file. Setting one here overwrites it in place, keeping the template's explanatory comment attached. Setting it to null deletes the line entirely and lets Gradle fall back to its own default.
One of them has a first-party equivalent. reactNativeArchitectures is also reachable as buildArchs in expo-build-properties. Either works — just don't set it in both places, or you'll spend an afternoon working out which one won.
Don't put secrets here. Signing passwords and API keys are a traditional gradle.properties habit, but anything you pass to this plugin lives in your app.config.ts, which is committed to git. Use EAS secrets or environment variables and read them in build.gradle instead.
reactNativeArchitectures is the property people most often come here for, so it's worth spelling out. It takes a comma-separated string, not an array:
plugins: [
['expo-gradle-properties', {
reactNativeArchitectures: 'arm64-v8a',
}],
]Android has four ABIs (CPU architectures):
| ABI | What it is | Do you need it? |
|---|---|---|
arm64-v8a |
64-bit ARM | Yes. Every modern phone, and required by Google Play. |
armeabi-v7a |
32-bit ARM | Only for older devices. Still supported — not deprecated. |
x86_64 |
64-bit Intel | Only for emulators (Apple Silicon and Intel). |
x86 |
32-bit Intel | Effectively legacy. Physical x86 Android devices are long gone. |
The Expo template builds all four. That default is safe but expensive: every native dependency you have — Reanimated, Gesture Handler, Nitro, MapLibre, SVG, Screens — gets its C++ compiled four separate times. On a large app that dominates build time.
Narrowing to one architecture is the single biggest build-time win available:
reactNativeArchitectures: 'arm64-v8a' // every modern phoneThe tradeoff is real: that build will not install on 32-bit ARM devices or on x86 emulators. It's ideal for local development and CI on Apple Silicon, and for testing on modern hardware.
To go back to the full set:
reactNativeArchitectures: 'armeabi-v7a,arm64-v8a,x86,x86_64'Narrowing ABIs does not shrink what your users download, if you ship an App Bundle (.aab) — which Google Play requires. Play splits the bundle per device and delivers only the matching ABI, so a user on an arm64 phone downloads arm64 code either way.
What narrowing does shrink is a universal APK — a dev-client build, an internal-distribution APK, or anything installed via adb. And what it always saves is build time.
Since August 2019, Play has required a 64-bit version of any app containing native code — you cannot publish 32-bit only. armeabi-v7a is not deprecated and 32-bit devices still exist, so the usual production answer is to ship both ARM ABIs and drop the x86 pair:
reactNativeArchitectures: 'armeabi-v7a,arm64-v8a'From Android 14, devices with ARMv9 cores cannot run 32-bit code at all, so the long-term direction is arm64-only — but that's not the situation today.
Existing keys are replaced in place. The entry keeps its original position, so a prebuild diff is one changed line rather than a deletion plus an append at the bottom — and the template's explanatory comment stays attached to the property it explains.
Duplicates are collapsed. If a key somehow appears twice, the first entry is replaced and the rest are deleted. This is a correctness requirement, not tidiness: gradle.properties is last-wins, so a plugin that blindly appends leaves a file that reads as though it worked while Gradle uses the other line.
New keys are appended at the end, under a single comment header, so anyone reading the generated file can see what put them there.
Removals are idempotent. Removing a key that isn't there is a no-op, and running prebuild twice produces a byte-identical file.
This is the part worth having.
Gradle resolves gradle.properties with the user-level file winning over the project-level one. Highest priority first:
- command line
-P $GRADLE_USER_HOME/gradle.properties— usually~/.gradle/gradle.properties<project>/gradle.properties← everything this plugin writes$GRADLE_HOME/gradle.properties
That inverts the usual "closest file wins" intuition, and it bites hard. A developer who once set org.gradle.jvmargs in ~/.gradle/gradle.properties gets builds that silently disagree with CI on the same commit. On a self-hosted runner, the runner's home file beats your repo for every build that machine will ever run.
So during prebuild the plugin reads that file and tells you:
[expo-gradle-properties] 1 property is overridden by your machine-level Gradle config.
~/.gradle/gradle.properties takes precedence over the project's gradle.properties, so the build will NOT use the value this plugin sets.
org.gradle.jvmargs
this plugin: -Xmx4g -XX:MaxMetaspaceSize=2g
~/.gradle/gradle.properties: -Xmx1g
Remove the key from ~/.gradle/gradle.properties, or pass warnOnUserOverride: false to silence this.
It warns and never throws — a machine-level override can be deliberate, and a config plugin has no business failing your build over your own dotfile. Set warnOnUserOverride: false to turn it off.
Expo writes gradle.properties as key=value with no escaping at all, so a malformed key produces a file Gradle quietly reads as something else. Everything below throws while your app config is being read, naming the key and the reason:
- keys containing whitespace,
=,:or\— all of these separate or escape entries - keys starting with
#or!— the line would be a comment - values containing a line break
- values ending in an odd number of backslashes — a trailing
\is a line continuation and would swallow the next property - non-finite numbers and objects
- arrays — with a message showing the joined string to write instead, since
expo-build-propertiestakes its ABI list as an array and the habit carries over - unknown options in the full form — because silently dropping an option is the bug this package exists to fix
Use expo-build-properties for anything it covers. It is first-party, and it edits build.gradle, Proguard rules and Maven repos, which this plugin deliberately does not touch.
expo-build-properties |
expo-gradle-properties |
|
|---|---|---|
| SDK versions, Kotlin, minify, Proguard, Maven repos | ✅ | ❌ (out of scope) |
reactNativeArchitectures |
✅ as buildArchs |
✅ |
org.gradle.jvmargs, kotlin.daemon.jvmargs, org.gradle.caching, custom flags |
❌ | ✅ |
| Unknown option | silently ignored | throws |
| iOS | ✅ | ❌ (no counterpart file) |
They compose — run both.
- Expo SDK 50 or newer
- Node 18+
- A prebuild (CNG) workflow. If you check
android/into git and never runexpo prebuild, edit the file directly instead.
bun install
bun run test # 99 tests
bun run typecheck
bun run buildThe interesting logic is pure and lives in src/apply.ts and src/normalize.ts; neither imports anything from Expo, so it can be tested without a prebuild. src/integration.test.ts round-trips through Expo's own parsePropertiesFile / propertiesListToString to prove the output is a file Expo will still read.
See DESIGN.md for why it is built this way.
Bug reports, failing test cases and doc fixes are all welcome — you don't need to bring a full solution. CONTRIBUTING.md covers the layout, where your change probably goes, and how to test against a real prebuild.
By taking part you agree to the Code of Conduct.
MIT © dangerdeveloper