●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
Shipping Six Wallpaper Apps From One Codebase: A White-Label Build Setup with app.config.ts and EAS
Maintaining near-identical wallpaper apps in separate repos means every fix has to be copied six times — and one day you miss one. Here is the white-label setup I moved to: one codebase that emits six apps through a single APP_VARIANT, with the real app.config.ts and eas.json, plus a validation script that catches config drift before the build runs.
A few years ago I had six wallpaper-app repositories sitting side by side on my machine. Different themes, different palettes, but the skeleton was almost identical: a grid, a favorites screen, AdMob banners and interstitials, a settings page. For the first two apps, treating them as "similar but separate" worked fine.
It broke down around the fourth one. When iOS Privacy Manifest support became mandatory, I applied the same change to all six repos by hand. I finished the fifth and felt done — only to discover weeks later that the sixth was still stale and had been rejected. I had skipped one repo somewhere in the copy-paste.
That was the moment it clicked: keeping near-identical apps in separate repos is the technical debt. This article is the record of how I collapsed those six into a single codebase that emits each app through one environment variable, APP_VARIANT. I'll show the actual working code — from making Expo's app.config.ts a dynamic function, through the EAS Build profile design, to the validation script that catches a missing variant field before the build ever starts.
Why sharing components wasn't enough
My first instinct was to extract shared components into an npm package or a monorepo workspace. That helps with UI duplication. But what actually hurt me wasn't the UI — it was managing the one-off pieces that differ per app.
Concretely: the bundle ID (something like com.dolice.wallpaper.zen), the app name, the icon, the splash image, the AdMob app ID and each ad-unit ID, the deep-link scheme, and the theme's base color. These are differences that can't be shared away, so they survive any amount of component extraction. Even in a monorepo, if I still hand-write these into each package's app.json, the copy-paste risk never disappears.
So I flipped the approach. Make the code exactly one thing. Confine the differences to variant data. At build time, when I pass APP_VARIANT=zen, app.config.ts reads that variant's data and assembles the right app.json equivalent on the spot. With this shape, a shared fix like Privacy Manifest is edited in one place and propagates to all six. And because the differences all live in one file, review can actually catch an omission.
Define variants as typed data
First, collect the six apps' differences into a single typed registry. This is the heart of the design. I deliberately attach an explicit type rather than relying on as const, because the validation script below uses it to catch missing keys.
// config/variants.tsexport type VariantId = "zen" | "aurora" | "noir" | "sakura" | "ocean" | "mono";export interface AppVariant { id: VariantId; name: string; // store display name / home-screen name slug: string; // expo slug (project identity) scheme: string; // deep-link URL scheme bundleId: string; // shared iOS / Android applicationId themeColor: string; // splash background / status bar base color admobAppIdIos: string; admobAppIdAndroid: string; easProjectId: string; // EAS project ID (one per variant)}export const VARIANTS: Record<VariantId, AppVariant> = { zen: { id: "zen", name: "Zen Wallpapers", slug: "zen-wallpapers", scheme: "dolicezen", bundleId: "com.dolice.wallpaper.zen", themeColor: "#1B1B1F", admobAppIdIos: "ca-app-pub-0000000000000000~1111111111", admobAppIdAndroid: "ca-app-pub-0000000000000000~2222222222", easProjectId: "00000000-0000-0000-0000-000000000001", }, // aurora / noir / sakura / ocean / mono follow in the same shape} as Record<VariantId, AppVariant>;export function resolveVariant(): AppVariant { const id = process.env.APP_VARIANT as VariantId | undefined; if (!id) { throw new Error( "APP_VARIANT is not set. Example: APP_VARIANT=zen npx expo prebuild" ); } const variant = VARIANTS[id]; if (!variant) { const known = Object.keys(VARIANTS).join(", "); throw new Error(`Unknown APP_VARIANT: "${id}". Valid values: ${known}`); } return variant;}
The important part is that resolveVariant()never silently accepts an unset value or a typo. If you run eas build having forgotten APP_VARIANT, a default value can produce "the sixth app built as Ocean using Zen's config." I once nearly uploaded an app under a different app's bundle ID this way. Throwing immediately removes almost the entire class of these accidents.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A dynamic app.config.ts that switches bundle ID, icon, splash, AdMob IDs, and theme color from a single APP_VARIANT, backed by a typed variant registry (working code)
✦The failure that convinced me: running the same fix across six repos by hand, finishing five, and leaving the sixth stale and rejected — plus how I decided to collapse them into one codebase
✦A TypeScript validation script that fails the build when a variant is missing an AdMob ID or an icon, and how to wire it into CI so you never drift back to copy-paste
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
If you place an app.config.ts (exporting a function) instead of app.json, Expo evaluates it at build time. That's where we load the variant and assemble the config from its values.
Place assets under assets/variants/{id}/ using the same file names per variant (icon.png, splash.png, adaptive-icon.png). Since paths are built from v.id, adding a new variant means adding one folder and one entry in variants.ts.
I include extra.variantId and extra.themeColor so the runtime code can also know which variant it is. Reading Constants.expoConfig?.extra?.themeColor via expo-constants lets you pull the theme color from the single source of truth in variants.ts instead of hardcoding it.
A local prebuild is just APP_VARIANT=zen npx expo prebuild. For EAS Build, fix the environment variable per profile so CI selects the right variant from --profile alone. Put the variant in env inside eas.json.
Inherit shared settings with extends: "base" and keep the only difference the APP_VARIANT in env. Putting the variant name in the profile name means that the moment you type eas build --profile ocean-production, you yourself can see what's being built. The ascAppId on the eas submit side is the one thing that has to be per variant, so write it out alongside.
The official docs introduce APP_VARIANT mainly to "separate development and production builds," but the real value in production is that the exact same mechanism transfers directly to being your multi-app axis. Expo's design was built to hold up to this kind of use.
One thing the docs underplay: always give each variant its own easProjectId. If you share one, an EAS OTA update you meant for Zen can land on Ocean's users too — a low-reproducibility incident that's miserable to trace. I initially reused a single project ID across all six and gave myself a scare when OTA deliveries crossed wires. Once I split the project IDs and pulled extra.eas.projectId from the variant, mis-targeted deliveries stopped entirely.
A validation script that fails before the build
This is the last line of defense against drifting back to copy-paste. An omission — added a variant but forgot the AdMob ID, forgot to create the icon folder — will let the build pass and stay hidden until right before store submission. Getting rejected after submitting costs half a day to a full day. So I drop these failures mechanically, before the build.
// scripts/validate-variants.tsimport { existsSync } from "node:fs";import { VARIANTS, type AppVariant } from "../config/variants";const REQUIRED_ASSETS = ["icon.png", "splash.png", "adaptive-icon.png"];const errors: string[] = [];for (const [id, v] of Object.entries(VARIANTS) as [string, AppVariant][]) { // 1. required fields not empty or placeholder for (const key of ["name", "bundleId", "scheme", "easProjectId"] as const) { if (!v[key] || v[key].trim() === "") { errors.push(`[${id}] ${key} is empty`); } } // 2. AdMob IDs are real-looking (reject test/dummy IDs) for (const key of ["admobAppIdIos", "admobAppIdAndroid"] as const) { if (!/^ca-app-pub-\d{16}~\d{10}$/.test(v[key])) { errors.push(`[${id}] ${key} has an invalid or missing format`); } } // 3. duplicate bundle ID (a clash overwrites a different app) const dup = Object.values(VARIANTS).filter((o) => o.bundleId === v.bundleId); if (dup.length > 1) { errors.push(`[${id}] bundleId "${v.bundleId}" collides with another variant`); } // 4. assets actually exist for (const asset of REQUIRED_ASSETS) { const path = `assets/variants/${id}/${asset}`; if (!existsSync(path)) { errors.push(`[${id}] missing asset: ${path}`); } }}if (errors.length > 0) { console.error("Variant validation failed:\n" + errors.map((e) => " - " + e).join("\n")); process.exit(1);}console.log(`✅ all ${Object.keys(VARIANTS).length} variants passed validation`);
Run it as a prebuild step in package.json or as the first step in CI: node --import tsx scripts/validate-variants.ts. The third check — duplicate bundle IDs — looks minor but earns its keep. When you create a new variant by copying an existing entry, it's easy to forget to change bundleId, and building in that state can overwrite a different app on upload. You can't catch a duplicate value with types, so you walk the data and fail on it.
If you run it on EAS's remote builds, put the validation in the eas-build-pre-install hook so the cloud build halts before it starts. Placing the same gate both locally and in CI was the configuration that betrayed me least over the long run.
Where to start the migration — don't do all six at once
Finally, the order for collapsing multiple apps that already run in separate repos. I first tried to integrate everything at once and froze. The right move was to make the most actively-updated app the "reference variant," build the code out completely there, and reduce the rest to just adding variant definitions.
In practice: move the most frequently updated app's code into the new single repo first, and rework it to emit via APP_VARIANT. Once that one ships reliably, test whether the second app can be reproduced with nothing more than "one entry in variants.ts + an asset folder + an EAS profile." If it reproduces, that's proof the design is right. If it needs an extra code branch, that branch is a signal: the difference hasn't been fully extracted into variants.ts yet.
My grandfather was a miyadaiku — a traditional shrine carpenter — and I remember him finishing slightly different fittings from the same drawing. The frame is shared; the per-site difference drops down into dimensions, into data. Once I realized software can do the same thing, maintaining the six apps got a lot quieter.
I hope this helps any solo developer wrestling with config drift. Start by picking two similar apps you already have, make one of them the reference variant, and write two entries in variants.ts.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.