react-native-flavor
v0.1.0
Published
Generate multiple flavors/environments for React Native (Expo CLI) — Android productFlavors + iOS schemes/xcconfig + a JS runtime layer, from a single config file.
Maintainers
Readme
react-native-flavor
Generate multiple flavors / environments for a React Native (Expo CLI) app from a single config file. Declare your flavors once, run one command, and react-native-flavor generates all the native plumbing:
- Android —
productFlavors,flavorDimensions, typedbuildConfigField(String/int/boolean/…), per-flavorapplicationId,app_name,manifestPlaceholders,resValue,customConfig(verbatim escape-hatch), global app-levelresValues/buildConfigFields, and Firebasegoogle-services.json. - iOS — per-flavor
.xcconfig,Debug/Release(and opt-inProfile) build configurations cloned per flavor, shared schemes (MyApp-<flavor>),Info.plistkeys, per-target variables, custom#includes, global/per-flavorbuildSettings, a Firebase copy build phase, and the Podfile config mapping. - Icons — per-flavor launcher / app icons: Android legacy mipmaps and adaptive
icons (API 26+, incl. explicit foreground/background/monochrome layers) in each flavor
source set, plus an
AppIcon-<flavor>set in the iOS asset catalog. - JS/TS runtime —
src/flavors.tswithFlavor.current, plus a native bridge (RNFlavor) so the running flavor is readable from JavaScript. - DX —
package.jsonrun scripts and.vscode/launch.jsonentries.
It is idempotent (re-run as often as you like) and works in two modes: write
straight into ios/+android/ (bare), or as an Expo config plugin that re-applies
on every expo prebuild.
⚠️ Currently targets Expo CLI projects (managed with CNG, or bare).
Install
npm install --save-dev react-native-flavor
# or
yarn add -D react-native-flavor1. Declare flavors (quick start)
Create flavors.yaml at your project root (flavors.json or a reactNativeFlavor
block in package.json also work):
app:
android:
flavorDimensions: "env" # name of the productFlavors dimension
ios:
buildConfigurations: [Debug, Release]
flavors:
production:
app: { name: "MyApp" }
android: { applicationId: "com.example.myapp" }
ios: { bundleId: "com.example.myapp" }
uat:
app: { name: "MyApp (uat)" }
android: { applicationId: "com.example.myapp.uat" }
ios: { bundleId: "com.example.myapp.uat" }That is the minimum. Every other field below is optional. See
example/flavors.yaml for a fully-loaded example.
2. Configuration reference
The config has two parts: a top-level app block (project-wide defaults) and a
flavors map (one entry per environment). Paths in the config are resolved
relative to the config file (e.g. flavors.yaml).
2.1 Top-level app
| Field | Type | Default | What it does |
|---|---|---|---|
| app.android.flavorDimensions | string | "flavor-type" | The dimension all flavors belong to. Emits flavorDimensions "<name>" and dimension "<name>" inside each flavor. |
| app.android.buildConfigField | boolean | true | When true, enables buildFeatures { buildConfig true } (required by AGP 8) and generates a BuildConfig.FLAVOR string for every flavor. Set false to skip the FLAVOR field. |
| app.android.resValues | map<"type/name", string> | {} | Global resValues applied to every flavor (a per-flavor resValues entry wins on key collision). |
| app.android.buildConfigFields | map | {} | Global buildConfigFields applied to every flavor (per-flavor wins). Same value syntax as the per-flavor field. |
| app.ios.buildConfigurations | string[] | [Debug, Release] | Base Xcode configurations cloned into <Base>-<flavor> (e.g. Debug-uat). Add Profile for the 3-mode Flutter layout — it has no base in RN/Expo so it's cloned from Release automatically. |
| app.ios.buildSettings | map<string,string> | {} | Global Xcode build settings merged into every flavor's build configs (e.g. DEVELOPMENT_TEAM). |
| app.ios.includes | list | [] | Global xcconfig #includes added to every flavor's per-mode wrapper. String or { value, target, optional }. |
| app.ios.projectName | string | auto-detected | Override the .xcodeproj / scheme base name. Set this when ios/ contains more than one .xcodeproj (e.g. a stale folder after a rename). |
| runtime | "native-module" | "react-native-config" | "native-module" | Runtime backend that exposes the flavor to JS. Only native-module is implemented today. |
2.2 Per-flavor: app and the shared icon
| Field | Type | Required | What it does |
|---|---|---|---|
| <flavor>.app.name | string | ✅ | The app's display name for this flavor. Android: emitted as resValue "string", "app_name", ...; iOS: used as the default DISPLAY_NAME (→ CFBundleDisplayName). |
| <flavor>.icon | string (path) | — | Square source icon (≥ 1024×1024 PNG) applied to both platforms. Override per platform with android.icon / ios.icon. See §3. |
2.3 Per-flavor: android
| Field | Type | Notes |
|---|---|---|
| applicationId | string | The full Android package id for this flavor. Use this or applicationIdSuffix, not both. |
| applicationIdSuffix | string | Appended to the base applicationId from app.json. Mutually exclusive with applicationId. |
| versionNameSuffix | string | Appended to the version name (e.g. -uat). |
| manifestPlaceholders | map<string,string> | Emitted as manifestPlaceholders = [key: "value", ...]. Reference them in AndroidManifest.xml as ${key}. |
| buildConfigFields | map | Shorthand KEY: "value" → buildConfigField "String", "KEY", "\"value\"" (auto-escaped; surrounding quotes are stripped first). For a non-string field use the object form KEY: { type: int\|long\|double\|float\|boolean\|char\|String, value: … } → e.g. MAX: { type: int, value: 5 } becomes buildConfigField "int", "MAX", "5". Read in Kotlin/Java as BuildConfig.KEY. |
| resValues | map<"type/name", string> | Each entry becomes resValue "type", "name", "value" (e.g. string/build_env: "uat"). string/app_name is added automatically from app.name; you can override it here. |
| customConfig | map<string, string|number> | Arbitrary lines injected verbatim into the productFlavor block as key value — the escape-hatch for anything not modeled above (versionCode: 42, signingConfig: signingConfigs.release, minSdkVersion: 24, …). String values that need Groovy quotes must include them: versionNameSuffix: '"-uat"'. |
| firebase.config | string (path) | Per-flavor google-services.json. See §4. |
| icon | string (path) | Android-only icon source (overrides the shared icon). |
| adaptiveIcon | { foreground, background?, monochrome? } | Explicit adaptive-icon layers (overrides the icon-derived adaptive layers). foreground/monochrome are image paths (authored at full 108dp canvas); background is a hex color or an image path. monochrome enables Android 13+ themed icons. |
| iconBackground | string (hex color) | Adaptive-icon background when deriving layers from a single icon (default #FFFFFF). |
2.4 Per-flavor: ios
| Field | Type | Required | Notes |
|---|---|---|---|
| bundleId | string | ✅ | PRODUCT_BUNDLE_IDENTIFIER for this flavor. Written into both the .xcconfig and the build settings so it always wins. |
| variables | map | — | Keys written into the flavor's xcconfig (KEY = value). Shorthand KEY: "value" applies to every build mode (lands in <flavor>.xcconfig). The object form KEY: { value, target } with target ∈ debug/profile/release scopes the variable to that mode only (lands in <flavor>.<mode>.xcconfig). DISPLAY_NAME (defaults to app.name) and FLAVOR (defaults to the flavor id) are added automatically. // is escaped to /$()/. Expose to native via $(KEY) in Info.plist. |
| buildSettings | map<string,string> | — | Extra Xcode build settings merged into this flavor's build configs (project + target level), e.g. OTHER_SWIFT_FLAGS. Merged after app.ios.buildSettings; bundleId/app-icon still win. |
| includes | list | — | Custom xcconfig #includes for this flavor. String or { value, target, optional } — optional: true emits #include?; target scopes it to one mode. |
| firebase.config | string (path) | — | Per-flavor GoogleService-Info.plist. See §4. |
| icon | string (path) | — | iOS-only icon source (overrides the shared icon). |
Where do these values surface in JS?
src/flavors.tsexposesFlavor.config.variables, which mergesandroid.buildConfigFieldsandios.variables(exceptDISPLAY_NAME/FLAVOR) at generation time — soFlavor.config.variables.API_BASE_URLworks on both platforms. See §7.
2.5 Validation rules
- Flavor ids must match
[a-z][a-z0-9]*and may not bedebug,release,main,androidtest,test,profile(they collide with Android buildTypes / source sets). - Each flavor needs
app.name, anandroid.applicationIdorandroid.applicationIdSuffix, and anios.bundleId. applicationId/bundleIdmust be unique across flavors (so multiple flavors can be installed side-by-side).
2.6 Full annotated example
app:
android:
flavorDimensions: "env" # default: "flavor-type"
buildConfigField: true # generate BuildConfig.FLAVOR (default true)
ios:
buildConfigurations: [Debug, Release]
projectName: "MyApp" # optional: only needed if ios/ has >1 .xcodeproj
flavors:
production:
app: { name: "MyApp" }
icon: "assets/icons/production.png"
android:
applicationId: "com.example.myapp"
versionNameSuffix: ""
iconBackground: "#1E66F5"
manifestPlaceholders:
deepLinkScheme: "myapp"
buildConfigFields:
API_BASE_URL: '"https://api.example.com"'
ENABLE_LOGS: "false"
resValues:
string/build_env: "production"
firebase:
config: "firebase/production/google-services.json"
ios:
bundleId: "com.example.myapp"
variables:
DISPLAY_NAME: "MyApp"
API_BASE_URL: "https://api.example.com"
firebase:
config: "firebase/production/GoogleService-Info.plist"
uat:
app: { name: "MyApp (uat)" }
icon: "assets/icons/uat.png"
android:
applicationId: "com.example.myapp.uat"
versionNameSuffix: "-uat"
iconBackground: "#F5A623"
buildConfigFields:
API_BASE_URL: '"https://uat.example.com"'
ENABLE_LOGS: "true"
resValues:
string/build_env: "uat"
ios:
bundleId: "com.example.myapp.uat"
variables:
DISPLAY_NAME: "MyApp (uat)"
API_BASE_URL: "https://uat.example.com"3. Per-flavor icons
Point a flavor at a square source image (≥ 1024×1024 PNG recommended). It is resized into every density bucket — you don't pre-cut sizes:
flavors:
uat:
app: { name: "MyApp (uat)" }
android: { applicationId: "com.example.myapp.uat" }
ios: { bundleId: "com.example.myapp.uat" }
icon: "assets/icons/uat.png" # applies to both platforms
# or override per platform / set the adaptive background:
# android: { applicationId: "...", icon: "assets/icons/uat-android.png", iconBackground: "#0A84FF" }
# ios: { bundleId: "...", icon: "assets/icons/uat-ios.png" }What gets generated for a flavor with an icon:
- Android — full launcher set in
android/app/src/<flavor>/res/:mipmap-*/ic_launcher.png(+_round) at all five densities for API < 26, and an adaptive icon (mipmap-anydpi-v26/ic_launcher.xmlwith generatedic_launcher_foreground/ic_launcher_backgroundlayers) so the override also wins on API 26+.iconBackground(default#FFFFFF) is the adaptive backdrop. Gradle merges these oversrc/main/resautomatically — no gradle edits. - iOS —
AppIcon-<flavor>.appiconset(single 1024² universal image, the modern Xcode format) in your asset catalog; the flavor's.xcconfigand build settings select it viaASSETCATALOG_COMPILER_APPICON_NAME. Xcode'sactoolgenerates every required size from the single source at build time.
Flavors without an icon keep the project's default launcher/app icon. Image
resizing uses the pure-JS jimp-compact
(no native binaries) — it ships as a dependency, so icons work out of the box.
After an iOS icon change, Xcode may cache the old asset catalog. If the icon doesn't update, delete the build cache and the app, then rebuild:
rm -rf ~/Library/Developer/Xcode/DerivedData/<App>-*and uninstall the app from the simulator.
4. Firebase
Each flavor is a separate Firebase app (one per applicationId / bundleId), so it
gets its own config file. Download them from the Firebase console and point each flavor at
its files:
flavors:
production:
android:
applicationId: "com.example.myapp"
firebase: { config: "firebase/production/google-services.json" }
ios:
bundleId: "com.example.myapp"
firebase: { config: "firebase/production/GoogleService-Info.plist" }
uat:
android:
applicationId: "com.example.myapp.uat"
firebase: { config: "firebase/uat/google-services.json" }
ios:
bundleId: "com.example.myapp.uat"
firebase: { config: "firebase/uat/GoogleService-Info.plist" }What the generator does (paths are relative to the config file):
- Android — copies each
google-services.jsoninto the flavor source setandroid/app/src/<flavor>/google-services.json. The Google Services Gradle plugin then picks the correct file per variant automatically — no gradle edits, no manual swapping. - iOS — copies each
GoogleService-Info.plistintoios/flavors/firebase/<flavor>/GoogleService-Info.plistand adds a Run Script build phase (react-native-flavor: Copy Firebase config) that copies the right plist into the built.appat compile time, keyed on$(CONFIGURATION)(e.g. configDebug-uatmatches theuatflavor). If a configuration doesn't match, the first flavor's plist is used as a fallback.
You still need the Firebase SDK set up the usual way — e.g.
@react-native-firebase/app, and on Android thecom.google.gms.google-servicesGradle plugin applied. react-native-flavor only places the per-flavor config files in the right spot; it does not install Firebase for you.
Tip: keep the config files out of version control if your policy requires it, and commit only
*.exampleplaceholders.
5. Generate
Mode A — bare (recommended for simplicity)
Make sure native folders exist (npx expo prebuild), then:
npx react-native-flavor # or the short alias: npx rn-flavor
cd ios && pod install && cd .. # new build configs need a Pods refreshCommit the generated ios/ + android/ changes and stop running
expo prebuild --clean (it would wipe native patches).
Mode B — Expo config plugin (keeps Continuous Native Generation)
Register the plugin so prebuild re-applies everything:
// app.config.js
module.exports = {
// ...
plugins: [
['react-native-flavor', { /* config: 'flavors.yaml' */ }],
],
};npx expo prebuild --clean
cd ios && pod install && cd ..Keep your
app.json/app.config.jsandroid.packageandios.bundleIdentifierset to the base id (your production/default id). react-native-flavor layers the per-flavor ids on top; the base id must stay first so Expo resolves the right launch target.
6. Build & run a flavor
No re-generation needed between flavors — just pick the variant/scheme. Convenience
scripts are added to package.json:
npm run android:uat # expo run:android --variant uatDebug --app-id com.example.myapp.uat
npm run ios:uat # expo run:ios --scheme MyApp-uat --configuration Debug-uat(--app-id is added for flavors with an explicit applicationId so Expo launches the
correct app instead of the base package.)
Release builds:
cd android && ./gradlew assembleUatRelease # or bundleUatRelease
# iOS: open Xcode, pick scheme "MyApp-uat", Product ▸ Archive7. Read the flavor at runtime
import { Flavor } from './src/flavors';
if (Flavor.is('uat')) showDebugBanner();
const apiUrl = Flavor.config.variables.API_BASE_URL;
console.log(Flavor.current); // 'uat'Flavor.current is read from native (BuildConfig.FLAVOR on Android, the Flavor
key in Info.plist on iOS) via the generated RNFlavor native module.
Flavor.config.variables is a compile-time merge of android.buildConfigFields and
ios.variables, so the same keys are available on both platforms.
Android registration: react-native-flavor tries to register
RNFlavorPackage()in yourMainApplication. If it cannot find the insertion point it prints a warning — addpackages.add(RNFlavorPackage())togetPackages()manually. iOS auto-registers viaRCT_EXPORT_MODULE.
CLI
react-native-flavor [options] # alias: rn-flavor
-c, --config <path> config path (default: auto-detect)
-p, --processors <list> run a subset, comma-separated (e.g. android:gradle,js:flavors)
--platforms <list> android,ios (default: both)
--dry-run print what would change, write nothing
-f, --force rebuild managed blocks even if present
-v, --verboseProcessors run in this order:
android:gradle android:manifest android:firebase native:bridge
ios:xcconfig ios:buildConfigs ios:schemes ios:plist ios:podfile
icons js:flavors scripts:package ide:vscodeHow idempotency works
- Text files (
build.gradle,Podfile) are wrapped inreact-native-flavor:start … react-native-flavor:endmarker blocks — re-running replaces only the block. (The Gradle block is placed afterdefaultConfigso Expo resolves the baseapplicationIdcorrectly.) project.pbxprojis edited through thexcodelibrary; configs/schemes already present (by name) are left alone (use--forceto rebuild them).- Generated files (
src/flavors.ts,.xcconfig, native bridge, schemes, icons) are rewritten wholesale with aGENERATED — do not editheader. - A
.rn-flavor/lockfile.jsonrecords the config hash and generated files.
Programmatic API
const { generate, loadFlavorConfig } = require('react-native-flavor');
await generate({ dryRun: true });
// Pure transforms (used by both the CLI and the Expo plugin):
const core = require('react-native-flavor/core');
const next = core.injectProductFlavors(buildGradleContents, config);Roadmap
- [x] Per-flavor launcher / app icons (Android mipmaps + adaptive, iOS asset catalogs)
- [ ]
react-native-configruntime backend option - [ ] Per-flavor launch screens (iOS)
- [ ] React Native CLI (non-Expo) support
License
MIT
