Set up development and production build identities so both versions can be installed side-by-side on the same machine or device, with distinct icons, app names, bundle IDs, and data isolation. Use when the user asks to "separate dev and prod builds", "add dev icons", "install dev and prod apps side by side", "add build variants", "differentiate development builds from production", or wants to run a local development build alongside a production release without conflicts.
From recipesnpx claudepluginhub ichabodcole/project-docs-scaffold-template --plugin recipesThis skill uses the workspace's default tool permissions.
Provides UI/UX resources: 50+ styles, color palettes, font pairings, guidelines, charts for web/mobile across React, Next.js, Vue, Svelte, Tailwind, React Native, Flutter. Aids planning, building, reviewing interfaces.
Fetches up-to-date documentation from Context7 for libraries and frameworks like React, Next.js, Prisma. Use for setup questions, API references, and code examples.
Calculates TAM/SAM/SOM using top-down, bottom-up, and value theory methodologies for market sizing, revenue estimation, and startup validation.
Set up a system where development and production builds of the same application have distinct identities -- different app names, icons, bundle IDs, and data directories -- so both can be installed and run simultaneously on the same machine or device. This lets developers use their own app as a real user (production build) while testing new features in a visually distinct development build, without either build interfering with the other.
This recipe is a hybrid: the architecture pattern is technology-agnostic, but implementation guidance is provided for three common stacks (Electron, Expo/React Native, and server-side Node/Bun APIs).
When you build an app in development, it typically uses the same bundle ID, app name, and data directory as the production version. Installing the dev build overwrites the production build. You lose your real data and can't use both simultaneously.
Every touchpoint where the OS or device identifies the app must differ between variants:
PRODUCTION BUILD DEVELOPMENT BUILD
───────────────── ─────────────────
App Name: "MyApp" App Name: "MyApp Dev"
Bundle ID: com.myapp Bundle ID: com.myapp.dev
Icon: Black background Icon: Purple background
Data Dir: ~/MyApp/ Data Dir: ~/MyAppDev/
Database: myapp.db Database: myapp.dev.db
URL Scheme: myapp:// URL Scheme: myapp-dev://
API URL: https://api.myapp.com API URL: http://localhost:3011
1. Build-time identity (OS-level, prevents overwrite):
com.myapp vs com.myapp.dev)2. Runtime configuration (app-level, prevents data collision):
3. Environment variables (connection-level, points to correct services):
Why separate icons? Because you WILL forget which build you're running. A purple icon (dev) vs black icon (prod) provides instant recognition in the dock, taskbar, or home screen. This is the single most important visual cue.
Why separate data directories? Database operations during development (migrations, test data, schema experiments) must never touch production data. Separate directories provide complete isolation without any code-level filtering.
Why dotenv-flow? It provides cascading environment files (.env →
.env.{NODE_ENV} → .env.local) with no custom code. The NODE_ENV variable
selects which override file loads, and .env.local (gitignored) handles
machine-specific values like local IP addresses.
Why NOT feature flags? Build identity is about OS-level separation (bundle IDs, icons, data paths), not runtime behavior toggling. Feature flags solve a different problem. Build identity must be set at build/install time, not toggled at runtime.
Set up environment variable cascading with dotenv-flow.
1.1 Install dotenv-flow
npm install dotenv-flow
# or
pnpm add dotenv-flow
1.2 Create environment files
Create these files in each app's root:
.env # Base development configuration (committed)
.env.production # Production overrides (committed)
.env.local # Machine-specific overrides (gitignored)
.env.example # Template documenting all variables (committed)
1.3 Define variant-specific variables
.env (development defaults):
# App identity
APP_NAME="MyApp Dev"
PRODUCT_NAME="MyApp-Dev"
# App variant (development | production)
APP_VARIANT=development
# API endpoint
API_URL=http://localhost:3011
.env.production (production overrides):
APP_NAME="MyApp"
PRODUCT_NAME="MyApp"
APP_VARIANT=production
API_URL=https://api.myapp.com
.env.example (template with documentation):
# App display name (shown in UI, window titles)
APP_NAME="MyApp Dev"
# Product name for OS-level packaging (installer name, .app bundle name)
PRODUCT_NAME="MyApp-Dev"
# App variant controls identity separation:
# - 'development': Uses .dev bundle ID suffix, dev icons, separate data dir
# - 'production': Uses standard bundle ID, prod icons, production data dir
APP_VARIANT=development
# API URL (use .env.local for machine-specific IP on physical devices)
API_URL=http://localhost:3011
1.4 Add .env.local to .gitignore
.env.local
.env.*.local
1.5 Load in your app entry point
// Must be the FIRST import in your entry file
import "dotenv-flow/config";
For Vite-based apps, environment variables prefixed with VITE_ are
automatically available in the renderer via import.meta.env.VITE_*.
For Expo apps, variables prefixed with EXPO_PUBLIC_ are available at runtime
via expo-constants.
Validate: Run your app in dev mode and verify the correct variables are loaded. Add a console log of the app name to confirm.
Create visually distinct icon sets for each variant.
2.1 Organize icon directories
resources/ # or assets/images/
├── dev/
│ ├── icon.png # Primary icon (1024x1024)
│ ├── icon.icns # macOS (Electron only)
│ ├── icon.ico # Windows (Electron only)
│ └── icon.svg # Source SVG
├── prod/
│ ├── icon.png
│ ├── icon.icns
│ ├── icon.ico
│ └── icon.svg
For Expo/React Native, include platform-specific variants:
assets/images/
├── dev/
│ ├── icon.png # iOS + Android base (1024x1024)
│ ├── android-icon-foreground.png # Adaptive icon foreground (1024x1024)
│ ├── android-icon-background.png # Adaptive icon background (1024x1024)
│ ├── android-icon-monochrome.png # Material You themed icon (1024x1024)
│ └── splash-icon.png # Splash screen icon (1200x1200)
├── prod/
│ ├── icon.png
│ ├── android-icon-foreground.png
│ ├── android-icon-background.png
│ ├── android-icon-monochrome.png
│ └── splash-icon.png
2.2 Design dev icons for instant recognition
The dev icon must be immediately distinguishable from production at small sizes (dock icons, taskbar, phone home screen). Effective strategies:
Avoid subtle differences (slight opacity changes, small badges) -- they're invisible at small sizes.
Validate: Place both icons side by side at 32x32 and 64x64. Can you tell them apart instantly?
Create a single configuration module that derives all identity values from the build variant.
3.1 Create the app config module
This is the central source of truth for all variant-dependent values:
// app-config.ts (or branding.ts for mobile)
// Detect build environment from environment variables
// Priority: explicit build env flag > mode flag > dev server detection > default
const BUILD_ENV = getBuildEnvironment(); // implementation varies by platform
const IS_DEV = BUILD_ENV === "development";
const TECHNICAL_NAME = "myapp"; // Stable, never changes
export const APP_CONFIG = {
isDevelopmentBuild: IS_DEV,
// Display name (shown to users)
displayName: IS_DEV ? `${APP_NAME} [DEV]` : APP_NAME,
// Stable technical identifier (URIs, MCP, etc.)
technicalName: TECHNICAL_NAME,
// Data directory (separate filesystem locations)
dataDirectoryName: IS_DEV ? "MyAppDev" : "MyApp",
// Database filename (separate files even if in same directory)
databaseName: IS_DEV ? `${TECHNICAL_NAME}.dev.db` : `${TECHNICAL_NAME}.db`,
// URL scheme for deep links
urlScheme: IS_DEV ? `${TECHNICAL_NAME}-dev://` : `${TECHNICAL_NAME}://`,
} as const;
3.2 Platform-specific environment detection
Electron (Vite-based):
// Detection priority for Electron + electron-vite:
const BUILD_ENV =
import.meta.env.VITE_BUILD_ENV || // Explicit override
import.meta.env.MODE || // --mode flag (development|production)
(import.meta.env.DEV ? "development" : "production"); // Dev server detection
import.meta.env.MODE is set by the --mode flag passed to electron-vite. This
is important because pnpm run dev (dev server) and pnpm run build:dev
(packaged dev build) both need to resolve to development.
Expo/React Native:
import Constants from "expo-constants";
// Read from Expo config extra field (set in app.config.ts)
const appVariant = Constants.expoConfig?.extra?.appVariant;
const IS_DEV = appVariant === "development";
The Expo approach reads from extra config because environment variables are
baked into the native build at compile time via app.config.ts.
3.3 Use the config everywhere
Every place in the app that needs variant-aware behavior imports from this single module. Components, services, and initialization code never detect the environment themselves.
import { APP_CONFIG } from "@shared/app-config";
// Database initialization
const dbPath = join(dataDir, APP_CONFIG.databaseName);
// Window title
window.setTitle(APP_CONFIG.displayName);
Validate: Run both dev and prod builds. Verify different display names, database filenames, and data directories.
Configure electron-builder to produce distinct app bundles per variant.
4.1 Base electron-builder config
electron-builder.yml defines production defaults:
appId: com.myapp
productName: MyApp
icon: resources/prod/icon
mac:
artifactName: ${env.ARTIFACT_NAME}-${version}-${arch}.${ext}
win:
executableName: ${env.PRODUCT_NAME}
nsis:
artifactName: ${env.ARTIFACT_NAME}-${version}-setup.${ext}
Note the ${env.*} references -- electron-builder reads these from the
environment at build time. This is how the build scripts override identity
fields.
4.2 Build wrapper scripts
Create two scripts that set variant-specific env vars and invoke electron-builder with config overrides:
scripts/build-dev.js:
const { execSync } = require("child_process");
const path = require("path");
const dotenvFlow = require("dotenv-flow");
// Load .env (development mode)
dotenvFlow.config({
node_env: "development",
path: path.join(__dirname, ".."),
});
// Set dev identity
process.env.APP_ID = "com.myapp.dev";
process.env.PRODUCT_NAME = process.env.PRODUCT_NAME || "MyApp-Dev";
process.env.ARTIFACT_NAME = "myapp-dev";
const builderArgs = process.argv.slice(2).join(" ");
const configOverride = [
`--config.appId="${process.env.APP_ID}"`,
`--config.productName="${process.env.PRODUCT_NAME}"`,
`--config.icon="resources/dev/icon"`,
].join(" ");
execSync(`electron-builder ${builderArgs} ${configOverride}`, {
stdio: "inherit",
env: process.env,
});
scripts/build-prod.js:
const { execSync } = require("child_process");
const path = require("path");
const dotenvFlow = require("dotenv-flow");
// Load .env.production
dotenvFlow.config({ node_env: "production", path: path.join(__dirname, "..") });
// Set production identity
process.env.APP_ID = "com.myapp";
process.env.PRODUCT_NAME = process.env.PRODUCT_NAME || "MyApp";
process.env.ARTIFACT_NAME = "myapp";
const builderArgs = process.argv.slice(2).join(" ");
const configOverride = [
`--config.appId="${process.env.APP_ID}"`,
`--config.productName="${process.env.PRODUCT_NAME}"`,
`--config.icon="resources/prod/icon"`,
].join(" ");
execSync(`electron-builder ${builderArgs} ${configOverride}`, {
stdio: "inherit",
env: process.env,
});
Note on execSync: These build scripts use execSync to invoke
electron-builder as a child process. This is appropriate here because the input
is controlled (developer-defined CLI args, not user input). In application code
that processes user input, always use execFile or equivalent safe APIs to
prevent command injection.
4.3 Package.json scripts
{
"scripts": {
"dev": "electron-vite dev",
"dev:prod": "electron-vite dev --mode production",
"build:dev": "electron-vite build --mode development",
"build:prod": "electron-vite build --mode production",
"build:mac": "npm run build:prod && node scripts/build-prod.js --mac",
"build:mac:dev": "npm run build:dev && node scripts/build-dev.js --mac",
"build:win": "npm run build:prod && node scripts/build-prod.js --win",
"build:win:dev": "npm run build:dev && node scripts/build-dev.js --win"
}
}
Key distinction: electron-vite build --mode X compiles the app code with
the correct environment. The build wrapper script then packages the compiled
code into an installer with the correct identity. These are two separate steps.
4.4 Runtime icon selection (window icon)
The packaged app icon is set by electron-builder, but the window/taskbar icon is set in code:
import iconProd from "../../resources/prod/icon.png?asset";
import iconDev from "../../resources/dev/icon.png?asset";
const icon = APP_CONFIG.isDevelopmentBuild ? iconDev : iconProd;
const window = new BrowserWindow({
icon: icon,
title: APP_CONFIG.displayName,
// ...
});
Validate: Build both build:mac and build:mac:dev. Verify that:
~/Library/Application Support/)Configure Expo to produce distinct app builds per variant.
5.1 Dynamic app.config.ts
Expo's config file is evaluated at build time, making it the ideal place for variant-conditional configuration:
import { ExpoConfig, ConfigContext } from "expo/config";
import * as dotenvFlow from "dotenv-flow";
dotenvFlow.config();
export default ({ config }: ConfigContext): ExpoConfig => {
const appVariant =
process.env.EXPO_PUBLIC_APP_VARIANT || process.env.NODE_ENV || "production";
const isDev = appVariant === "development";
const iconFolder = isDev ? "dev" : "prod";
const iconPath = `./assets/images/${iconFolder}`;
return {
...config,
name: isDev ? "MyApp Dev" : "MyApp",
slug: isDev ? "myapp-dev" : "myapp",
icon: `${iconPath}/icon.png`,
scheme: isDev ? "myapp-dev" : "myapp",
ios: {
bundleIdentifier: isDev ? "com.myapp.dev" : "com.myapp",
},
android: {
package: isDev ? "com.myapp.dev" : "com.myapp",
adaptiveIcon: {
foregroundImage: `${iconPath}/android-icon-foreground.png`,
backgroundImage: `${iconPath}/android-icon-background.png`,
monochromeImage: `${iconPath}/android-icon-monochrome.png`,
},
},
plugins: [
[
"expo-splash-screen",
{
image: `${iconPath}/splash-icon.png`,
},
],
],
extra: {
apiUrl: process.env.EXPO_PUBLIC_API_URL || "https://api.myapp.com",
appVariant: process.env.EXPO_PUBLIC_APP_VARIANT || "production",
},
};
};
5.2 Package.json build scripts
Set NODE_ENV and EXPO_PUBLIC_APP_VARIANT explicitly in each script:
{
"scripts": {
"ios:dev": "NODE_ENV=development EXPO_PUBLIC_APP_VARIANT=development expo run:ios",
"ios:prod": "NODE_ENV=production EXPO_PUBLIC_APP_VARIANT=production expo run:ios",
"android:dev": "NODE_ENV=development EXPO_PUBLIC_APP_VARIANT=development expo run:android",
"android:prod": "NODE_ENV=production EXPO_PUBLIC_APP_VARIANT=production expo run:android",
"ios:release:dev": "NODE_ENV=development EXPO_PUBLIC_APP_VARIANT=development expo run:ios --configuration Release",
"ios:release:prod": "NODE_ENV=production EXPO_PUBLIC_APP_VARIANT=production expo run:ios --configuration Release",
"android:release:dev": "NODE_ENV=development EXPO_PUBLIC_APP_VARIANT=development expo run:android --variant release",
"android:release:prod": "NODE_ENV=production EXPO_PUBLIC_APP_VARIANT=production expo run:android --variant release"
}
}
Why set both NODE_ENV and your app variant variable in Expo scripts?
NODE_ENV controls which .env files dotenv-flow loads.
EXPO_PUBLIC_APP_VARIANT is the explicit variant flag baked into the Expo
config. They serve different purposes and both are needed because dotenv-flow
runs at config evaluation time, while the variant flag propagates into the
runtime bundle.
5.3 Runtime branding module
import Constants from "expo-constants";
export function getAppVariant(): "development" | "production" {
const variant = Constants.expoConfig?.extra?.appVariant;
return variant === "development" ? "development" : "production";
}
export function getAppBranding() {
const isDev = getAppVariant() === "development";
return {
displayName: isDev ? "MyApp Dev" : "MyApp",
databaseName: isDev ? "myapp.dev.db" : "myapp.db",
storageKeyPrefix: isDev ? "myapp-dev" : "myapp",
urlScheme: isDev ? "myapp-dev" : "myapp",
} as const;
}
Validate: Build both ios:dev and ios:prod (or the Android equivalents)
and install both on the same device/simulator. Verify:
The API server typically doesn't need build variants (there's only one running instance), but it does need environment-aware configuration that the client apps connect to.
6.1 Environment-variable-driven config with validation
// Load environment files first
import "dotenv-flow/config";
import { z } from "zod";
const envSchema = z.object({
DATABASE_URL: z.string(),
API_PORT: z.string().default("3011"),
ALLOWED_ORIGINS: z.string(),
// Feature detection: enable features based on presence of keys
SMTP_HOST: z.string().optional(),
AI_API_KEY: z.string().optional(),
DEBUG_PROMPTS: z.string().optional(),
});
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error("Environment validation failed:", parsed.error.format());
process.exit(1);
}
export const env = parsed.data;
Key insight: The API uses feature detection (is SMTP_HOST set?) rather
than environment detection (is NODE_ENV production?). This is more flexible --
features activate based on what credentials are available, not what environment
label is set.
6.2 CORS must include both dev and prod client origins
// Trusted origins must include client app URL schemes for mobile
const trustedOrigins = [
...env.ALLOWED_ORIGINS.split(",").map((s) => s.trim()),
"myapp://",
"myapp://*",
"myapp-dev://",
"myapp-dev://*",
];
6.3 Development vs production .env for the API
.env (development):
DATABASE_URL=postgresql://user:pass@localhost:5432/myapp
ALLOWED_ORIGINS="http://localhost:3000,http://localhost:5173"
DEBUG_PROMPTS=true
.env.production:
# Values provided at runtime via environment variables or Docker
# DATABASE_URL, ALLOWED_ORIGINS, etc. are set in deployment config
Validate: Start the API with dotenv-flow in development mode. Verify CORS
allows requests from your dev client URL. Start with production env and verify
debug logging is off.
If using Turborepo, list environment variables in turbo.json globalEnv so
that changes to environment variables invalidate the build cache:
{
"globalEnv": [
"DATABASE_URL",
"API_URL",
"APP_VARIANT",
"ALLOWED_ORIGINS",
"DEBUG_PROMPTS"
]
}
Without this, Turborepo may serve a cached build that was compiled with different environment values.
If your app uses cloud sync, ensure sync credentials point to the correct
backend for each variant. The dev build should sync with a development database,
not the production one. The API URL in your .env files controls this
automatically, but verify that your sync service configuration also reads from
environment variables.
Both variants register different URL schemes (myapp:// vs myapp-dev://). The
API's trusted origins list must include both schemes. If using OAuth callbacks
or deep links, ensure the redirect URIs are registered for both variants.
Both variants use separate database files but typically share the same schema.
Run migrations on both databases when the schema changes. In development, it's
common to use db:push (destructive schema sync) on the dev database while
using proper migrations on the production database.
| Setting | Dev Value | Prod Value | Where Set |
|---|---|---|---|
| App Name | "MyApp Dev" | "MyApp" | .env / .env.production |
| Bundle ID | com.myapp.dev | com.myapp | Build script / app.config.ts |
| Icon | resources/dev/icon | resources/prod/icon | Build script / app.config.ts |
| Data Directory | MyAppDev | MyApp | app-config.ts |
| Database | myapp.dev.db | myapp.db | app-config.ts |
| API URL | http://localhost:3011 | https://api.myapp.com | .env / .env.production |
| URL Scheme | myapp-dev:// | myapp:// | app-config.ts / app.config.ts |
| Debug Logging | Enabled | Disabled | .env |
The core pattern is identical: separate build configs per variant with different
app IDs and icons. Tauri uses tauri.conf.json where you'd override
identifier and icon per variant. The centralized app-config module pattern
works regardless of the desktop framework.
Without Expo's app.config.ts, configure variants via:
dev and prod) with different
applicationId and resource directoriesFlutter uses --dart-define for build-time configuration and flavors for
variant-specific assets. The pattern maps to:
--dart-define=APP_VARIANT=developmentapplicationId in build.gradle flavorsdotenv-flow is recommended because it handles cascading automatically. Other
options:
The .dev bundle ID suffix is a convention, not a requirement. Use
whatever suffix makes sense for your project, but .dev is widely understood
and immediately communicates the purpose. Some teams use .staging, .beta,
etc. for additional variants.
Data directory separation is non-negotiable. Even if you skip icon differentiation, you MUST separate data directories and database files. Running a dev migration on your production database is a disaster that's hard to recover from.
dotenv-flow loads .env first, then overlays. The base .env file is
always loaded. .env.production only overrides the values it explicitly sets.
This means your .env should contain development defaults, and
.env.production should only contain values that differ in production.
.env.local is for machine-specific values only. Things like your local
network IP address (needed for mobile development on physical devices) go in
.env.local, which is gitignored. Never put variant-specific configuration
here -- it should be in .env or .env.production.
Expo requires a fresh native build when changing bundle IDs. If you switch
between ios:dev and ios:prod, Expo must regenerate the native project
because the bundle identifier changes. This is expected -- it's building a
genuinely different app. Metro hot reload alone is not sufficient.
Electron-builder's ${env.*} syntax reads from process.env. The build
wrapper scripts set environment variables that electron-builder.yml references
via ${env.ARTIFACT_NAME}, ${env.PRODUCT_NAME}, etc. If these aren't set,
electron-builder uses the YAML defaults. This is intentional -- the YAML
defaults are production values, and the scripts override for dev.
Set both NODE_ENV and your app variant variable in Expo scripts.
NODE_ENV controls dotenv-flow file loading. The app variant variable (e.g.,
EXPO_PUBLIC_APP_VARIANT) is baked into the Expo config at build time. They
serve different purposes and both are needed.
Window titles should include a [DEV] suffix. This catches the case where
the dock/taskbar icon is too small to distinguish. The window title provides a
second visual cue.
Don't forget to update the API's trusted origins when adding a new
variant. If your mobile app uses URL schemes for auth callbacks (e.g., OAuth
redirect), the API must trust both myapp:// and myapp-dev://. Missing this
causes auth to silently fail only in development builds -- a confusing bug.
Icon generation tip. Design one master SVG with a parameterized background color. Generate all icon sizes and formats from this single source. When you change the dev color from purple to orange, regenerate all formats at once. This prevents mismatched icon sets.
pnpm run dev is not the same as pnpm run build:dev. The first runs a
dev server with hot reload (for active development). The second compiles and
packages a distributable dev build (for installing alongside production). They
both use development environment settings, but serve very different purposes.