RORK LABJP
TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude CodeSIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App StoreMAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessageNATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core MLSEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the roundTOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude CodeSIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App StoreMAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessageNATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core MLSEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
Articles/Dev Tools
Dev Tools/2026-06-02Advanced

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.

React Native209Expo149EAS6Architecture17Build

Premium Article

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.ts
export 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.

or
Unlock all articles with Membership →
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.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

Related Articles

Dev Tools2026-07-08
When the Device Runs Out of Space, What Should Your App Protect?
Design Expo/React Native apps that assume writes can fail. Free-space budgets, LRU reclaim, data-protection tiers, and telemetry that surfaces silent failures — drawn from running six wallpaper apps.
Dev Tools2026-06-22
When a Poisoned Cache Crashes Your App on Every Launch — Designing a Safe-Mode Boot Your Users Can Escape On Their Own
When a persisted cache goes bad and the app crashes at the same spot on every launch, the only option left to the user is to reinstall. This article designs a safe-mode boot for Expo (React Native): the app counts its own early crashes, confirms a launch only once it becomes interactive, and resets just the dangerous state in graduated steps.
Dev Tools2026-07-15
The Comma That Fell to the Start of a Line: Rork, Quote Cards, and Japanese Line Breaking
React Native's Text component does not guarantee Japanese line-breaking rules. Here are the violation rates I measured, a WORD JOINER implementation that survives copy and VoiceOver, an onTextLayout audit harness, and how Rork Max (SwiftUI) differs.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →