●APPLE-AI — Apple opens Foundation Models free to developers under 2M first-time downloads, slashing the cost of adding AI to indie apps●SWIFT-API — Foundation Models server-side integration lets you call Claude and Gemini through the same Swift API, now with image input●KOTLIN-MIGRATION — Android Studio's migration agent converts React Native apps into native Kotlin automatically — a future path for Rork-built apps●RORK-MAX — Rork Max generates native Swift code ($200/mo), covering iPhone, iPad, Watch, TV, Vision Pro, and iMessage●SIMULATOR — A browser-based streaming iOS simulator lets you test on a real Apple environment without Xcode or Mac hardware●SWIFTUI — SwiftUI evolves at WWDC 2026 with reorderable containers, swipe actions for any container, and layouts up to 2x faster●APPLE-AI — Apple opens Foundation Models free to developers under 2M first-time downloads, slashing the cost of adding AI to indie apps●SWIFT-API — Foundation Models server-side integration lets you call Claude and Gemini through the same Swift API, now with image input●KOTLIN-MIGRATION — Android Studio's migration agent converts React Native apps into native Kotlin automatically — a future path for Rork-built apps●RORK-MAX — Rork Max generates native Swift code ($200/mo), covering iPhone, iPad, Watch, TV, Vision Pro, and iMessage●SIMULATOR — A browser-based streaming iOS simulator lets you test on a real Apple environment without Xcode or Mac hardware●SWIFTUI — SwiftUI evolves at WWDC 2026 with reorderable containers, swipe actions for any container, and layouts up to 2x faster
Building a Developer Debug Menu Into Your Rork App — Verify Ads, Purchases, and Remote Config Before Release
A production-safe developer debug menu for Rork apps — switch environments, force test ads, simulate entitlements, and override Remote Config, with working TypeScript code and the pitfalls I hit running six apps.
In May 2026, while one of my wallpaper apps was partway through a staged rollout of v2.1.0 on Android, a review came in that said, roughly: I watched the rewarded ad to remove ads, and the paywall still showed up. On my debug build I could not reproduce it no matter what I tried. The root cause turned out to be a gap in how two entitlement flags were combined — more on that below — but the painful part was something else. At that moment, that app had no way to reproduce production conditions on my desk: the real Remote Config values, the real ad inventory, the real purchase state.
Bugs that only surface in release builds cluster around three things: ads, purchases, and remote configuration. Which means that if you build one screen that can safely flip those three from a device, most of this class of verification moves to before release, where it belongs. What follows is the design I have settled on as an indie developer running six wallpaper apps in parallel — a developer menu that is genuinely absent from store builds, not merely hidden.
Why Hiding Switches in the Settings Screen Falls Apart
The first thing most of us build — I did it for years — is a handful of __DEV__-guarded rows at the bottom of the settings screen. It falls apart for three reasons.
It scatters. The test-ad toggle lives in the ad module, the endpoint switch lives in the API client, and conditional branches creep across the whole codebase. Six months later you will not remember where anything is.
You forget to turn things off. A structure that makes you ask, the night before submission, did I flip that flag back? is itself the hazard. I once came one checklist item away from shipping a staging URL in a production build. The checklist caught it; the architecture had not.
Repro steps become folklore. Turn that on, open that screen three times, then it happens. Even when you work alone, your future self is a stranger who deserves better documentation.
So invert the approach. Concentrate every debug operation into one screen, and make both the entrance and the implementation disappear by build type. Instead of chasing scattered if statements, you design exactly one override layer.
The Shape of the Menu — Four Sections and Two Hard Rules
Across my six apps the menu has converged on four sections.
Environment: switching the API and content endpoints (dev / staging / production) and showing the current build variant
Ads: a test-ad mode toggle, the last five ad load results, and which mediation network filled each request
Purchases: forcing entitlements (ad-free, premium) and launching the sandbox purchase flow
Remote Config: overriding arbitrary keys and force-triggering modal campaigns
And two rules I have never relaxed, on any app.
Store builds must not contain the code at all — not just the entrance. A menu that is merely invisible is half a solution, both for review risk and for accident prevention.
While any override is active, a badge stays on screen. I once spent an embarrassing stretch of time reacting to numbers that were coming from a test state. The badge ended that era.
The reason I push the second rule so hard: the worst enemy of a debug menu is forgetting what you overrode. The more convenient overrides become, the more you forget them. Visibility of state is not decoration; it is part of the feature.
✦
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
✦If you have been burning evenings on bugs that only appear in release builds, you can ship a developer menu that reproduces ad, purchase, and config states on demand
✦You will learn a four-section design (environment, ads, entitlements, Remote Config) backed by working TypeScript code you can drop into a Rork project today
✦You will be able to strip every debug pathway out of store builds, with an operational checklist that keeps review risk and stale-override accidents near zero
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.
The Entrance — A Build Flag Plus the Seven-Tap Gesture
In an Expo project generated by Rork, the natural home for build-type declarations is app.config.ts. Receive a variant from the environment and enable debug features only for internal builds.
// app.config.ts — declare the variant in exactly one place// EAS Build profiles pass APP_VARIANT (e.g. internal / production)import { ExpoConfig } from "expo/config";const variant = process.env.APP_VARIANT ?? "development";const config: ExpoConfig = { name: variant === "production" ? "Beautiful Walls" : `Walls (${variant})`, slug: "beautiful-walls", extra: { appVariant: variant, // read at runtime via expo-constants }, // ...rest of the config omitted};export default config;
At runtime, confine that value to a single predicate. Not reading Constants directly all over the codebase is exactly the anti-scatter measure from the previous section.
// src/debug/isDebugMenuAvailable.ts — the only entry point for the checkimport Constants from "expo-constants";const variant: string = Constants.expoConfig?.extra?.appVariant ?? "development";// true only for development and internal; always false in productionexport const isDebugMenuAvailable = (): boolean => variant === "development" || variant === "internal";
For the entrance I use tap the version label seven times within three seconds. It mirrors the Android developer-options gesture, so testers need no explanation.
// src/debug/DebugGate.tsx — wired to the version label in settingsimport { useRef, useCallback } from "react";import { Pressable, Text } from "react-native";import { router } from "expo-router";import { isDebugMenuAvailable } from "./isDebugMenuAvailable";const REQUIRED_TAPS = 7;const WINDOW_MS = 3000; // 7 taps within 3 secondsexport function DebugGate({ version }: { version: string }) { const taps = useRef<number[]>([]); const onTap = useCallback(() => { if (!isDebugMenuAvailable()) return; // no-op in production const now = Date.now(); taps.current = [...taps.current.filter((t) => now - t < WINDOW_MS), now]; if (taps.current.length >= REQUIRED_TAPS) { taps.current = []; router.push("/debug"); // expo-router debug screen } }, []); return ( <Pressable onPress={onTap} accessibilityLabel="app version"> <Text>v{version}</Text> </Pressable> );}
Expected behavior: on development and internal builds, seven taps open /debug; on production builds, nothing happens. And with the routing trick later in this article, production never even loads the module behind that route, so a deep link pointed at it simply lands on home.
The Menu Itself — Design Exactly One Override Layer
The menu screen is an ordinary settings list; nothing about it is interesting. The substance is the override store behind it. The key decision: keep real values (what production logic computes) and override values separate, and merge them at read time. If overrides mutate the real state directly, you can no longer guarantee that clearing an override restores reality.
// src/debug/debugOverrides.ts — an AsyncStorage-backed override storeimport AsyncStorage from "@react-native-async-storage/async-storage";export type DebugOverrides = { apiEnv?: "dev" | "staging" | "production"; forceTestAds?: boolean; entitlement?: "none" | "adFree" | "premium"; remoteConfig?: Record<string, string | number | boolean>; expiresAt?: number; // auto-expiry against forgotten overrides};const KEY = "debug.overrides.v1";const TTL_MS = 24 * 60 * 60 * 1000; // expire after 24 hourslet cache: DebugOverrides = {};export async function loadOverrides(): Promise<DebugOverrides> { const raw = await AsyncStorage.getItem(KEY); if (!raw) return (cache = {}); const parsed: DebugOverrides = JSON.parse(raw); // discard when expired — so yesterday's overrides cannot ambush you today if (parsed.expiresAt && Date.now() > parsed.expiresAt) { await AsyncStorage.removeItem(KEY); return (cache = {}); } return (cache = parsed);}export function getOverrides(): DebugOverrides { return cache; // synchronous read (loadOverrides runs at startup)}export async function setOverride<K extends keyof DebugOverrides>( key: K, value: DebugOverrides[K]): Promise<void> { cache = { ...cache, [key]: value, expiresAt: Date.now() + TTL_MS }; await AsyncStorage.setItem(KEY, JSON.stringify(cache));}export async function clearOverrides(): Promise<void> { cache = {}; await AsyncStorage.removeItem(KEY);}export const hasActiveOverrides = (): boolean => Object.keys(cache).some((k) => k !== "expiresAt");
The 24-hour expiresAt auto-expiry was added two weeks into running this, not designed up front. Whatever I override on a Friday night, Monday me has no memory of. Once I stopped designing around the assumption that I would remember, the recurring false alarm of wait, why is this still broken — oh, it is my own override — disappeared.
The badge that signals active overrides sits in the root layout.
// overlaid at the end of app/_layout.tsx (internal builds only){isDebugMenuAvailable() && hasActiveOverrides() && ( <View pointerEvents="none" style={styles.debugBadge}> <Text style={styles.debugBadgeText}>OVERRIDE</Text> </View>)}
Test-Ad Mode — Keep Your Real Ad Units Clean
In ad work, the scary failure mode is not a crash; it is policy. Show yourself real ads during development, tap one absent-mindedly, and you are generating invalid traffic that can put a warning on your AdMob account. The bulk of the 50 million cumulative downloads across my apps has been monetized on the free-plus-ads model, so the health of that AdMob account is, quite literally, the lifeline. That is why switching to test ads is one tap in the menu rather than something entrusted to a build flag.
// src/ads/adUnitIds.ts — ad unit resolution that respects overridesimport { TestIds } from "react-native-google-mobile-ads";import { getOverrides } from "../debug/debugOverrides";import { isDebugMenuAvailable } from "../debug/isDebugMenuAvailable";const PRODUCTION_UNITS = { banner: "ca-app-pub-XXXXXXXXXXXXXXXX/NNNNNNNNNN", // inject real IDs from config interstitial: "ca-app-pub-XXXXXXXXXXXXXXXX/NNNNNNNNNN", rewarded: "ca-app-pub-XXXXXXXXXXXXXXXX/NNNNNNNNNN",} as const;type AdKind = keyof typeof PRODUCTION_UNITS;export function resolveAdUnitId(kind: AdKind): string { // with forceTestAds on an internal build, return Google's official test IDs if (isDebugMenuAvailable() && getOverrides().forceTestAds) { const testIds: Record<AdKind, string> = { banner: TestIds.ADAPTIVE_BANNER, interstitial: TestIds.INTERSTITIAL, rewarded: TestIds.REWARDED, }; return testIds[kind]; } return PRODUCTION_UNITS[kind];}
Besides the toggle, the ads section shows only the last five load results: which network filled, and what the error code was. During a stretch of mediation tuning, being able to read those five lines saved a full overnight round-trip of investigation more than once.
One more small habit: the badge changes color in test-ad mode (yellow for plain overrides, green while test ads are forced). It exists to prevent the inverse mistake — staring at eCPM while unknowingly still on test inventory.
Simulating Purchase State — Centralize the Check Before You Override It
The v2.1.0 bug from the opening was a composition gap in entitlements. Ad removal by one-time purchase lived in isAdFree; temporary ad removal earned by watching a rewarded ad lived in isRewardAdFree. A newly added paywall condition consulted only isAdFree. My own debug device had the purchase, so the bug was unreproducible at my desk — forever, by construction.
The lesson it left: make the check single-entry before making it overridable. While checks are scattered, no amount of override machinery closes every hole.
// src/entitlements/useEntitlements.ts — one merge point for real and override valuesimport { getOverrides } from "../debug/debugOverrides";import { isDebugMenuAvailable } from "../debug/isDebugMenuAvailable";import { usePurchaseState } from "./usePurchaseState"; // real purchase stateimport { useRewardAdFree } from "./useRewardAdFree"; // temporary, earned by rewarded adsexport type Entitlements = { adFree: boolean; premium: boolean; source: "purchase" | "reward" | "debug" | "none";};export function useEntitlements(): Entitlements { const purchase = usePurchaseState(); const rewardAdFree = useRewardAdFree(); // debug overrides take top priority — and are not even evaluated outside internal builds if (isDebugMenuAvailable()) { const o = getOverrides().entitlement; if (o === "adFree") return { adFree: true, premium: false, source: "debug" }; if (o === "premium") return { adFree: true, premium: true, source: "debug" }; if (o === "none") return { adFree: false, premium: false, source: "debug" }; } // merge real values — ad-free holds via either purchase or reward const adFree = purchase.adFree || rewardAdFree.active; return { adFree, premium: purchase.premium, source: purchase.adFree || purchase.premium ? "purchase" : rewardAdFree.active ? "reward" : "none", };}
Returning source earns its keep quietly. The purchases section of the menu can display whether the current ad-free state comes from a purchase or from a reward, so a composition gap like the one above becomes visible at a glance instead of after two days of guessing.
Remote Config values propagate with a delay, and toggling them server-side while you verify client behavior is not a workable loop. Override on the client instead.
// src/config/remoteValue.ts — the single window onto Remote Configimport remoteConfig from "@react-native-firebase/remote-config";import { getOverrides } from "../debug/debugOverrides";import { isDebugMenuAvailable } from "../debug/isDebugMenuAvailable";export function getRemoteValue<T extends string | number | boolean>( key: string, fallback: T): T { // overrides win when present (internal builds only) if (isDebugMenuAvailable()) { const o = getOverrides().remoteConfig?.[key]; if (o !== undefined) return o as T; } const v = remoteConfig().getValue(key); if (typeof fallback === "boolean") return v.asBoolean() as T; if (typeof fallback === "number") return v.asNumber() as T; return (v.asString() || fallback) as T;}
Say you ship an interstitial frequency cap as interstitial_min_interval_sec: 90. Override it to 30 from the menu, walk through a few screens, and you have exercised the boundary behavior of your frequency control in minutes. When I was verifying a system that throttles ad load as crash rates rise, I overrode the threshold to fabricate a braking-engaged state on demand — the architecture itself is written up in Auto-Throttling AdMob When Crash Rates Spike: A Revenue-Protecting Brake Architecture with Rork, Firebase Remote Config, and Crashlytics.
Force-firing modals is nothing more than buttons that push entries straight onto the ModalGate queue. Make the three usual suspects — review prompt, paywall, announcement — stackable in any order, and you can reproduce on your desk a collision that natural conditions produce maybe once a month.
Removing It From Store Builds — Not Invisible, Absent
The finishing move. In production builds, do not merely hide the entrance; keep the code out of the bundle. I branch the expo-router route by variant so that in production the debug screen's module is never even required.
// app/debug.tsx — a route that never loads its content in productionimport { Redirect } from "expo-router";import { isDebugMenuAvailable } from "../src/debug/isDebugMenuAvailable";export default function DebugRoute() { if (!isDebugMenuAvailable()) { return <Redirect href="/" />; // deep links bounce home } // lazy require — unreachable, and strippable, in production bundles const DebugMenu = require("../src/debug/DebugMenu").default; return <DebugMenu />;}
One Expo-specific warning belongs here. Every environment variable prefixed EXPO_PUBLIC_ is embedded in the JS bundle in plain text. That property is what makes build-time branching optimizable, but it also means debug auth tokens or a staff-detection email list must never live there. Treat it as a place for values you would be comfortable publishing, because you are publishing them.
On review: App Store guidelines are strict about hidden functionality, and rather than argue interpretation, keeping the menu out of submitted builds entirely is the one stance with no ambiguity. My EAS Build profiles are split into production (store submission, no menu) and internal (TestFlight internal testing and Play internal track, menu included). And if I ever start a submission from the wrong configuration, the app.config.ts naming trick from earlier means the submission screen greets me with an app called Walls (internal). Putting the variant in the product name is a primitive tripwire, and it works precisely because it is primitive.
Pitfalls — The Ones I Actually Hit Across Six Apps
Secrets in EXPO_PUBLIC_. As above: plain text in the bundle. Do not even put the debug menu passcode there. The correct protection is distribution scope — internal tracks only — not obfuscation.
Stale overrides. Since pairing the 24-hour TTL with the always-on badge, override-induced false alarms have stayed at zero. Either measure alone eventually fails; the pair has not.
Debug sessions polluting analytics. Impression events fired in test-ad mode quietly distort ARPDAU. My rule: while any override is active, every analytics event carries debug: true, and the pipeline filters them out. The event layer that makes that one-line change safe is described in Guarding Analytics Events With Types Across Six Rork Apps — A Shared Event Layer.
Crossed EAS Update channels. Push an internal JS bundle to the production channel and your the code does not exist in production guarantee evaporates. I keep channel names identical to variant names and have the deploy script verify the pair before publishing; it has not happened since.
Overrides surviving between internal builds. AsyncStorage overrides persist across app updates. Before testing version migrations, press Clear All at the bottom of the menu, every time. Production builds are safe regardless — isDebugMenuAvailable() is false there, so leftover values are never even evaluated.
Where to Start — A Variant Badge Is Enough
You do not need to build all of this at once. Declare the variant in app.config.ts and put a small internal badge in a corner of the screen. That alone removes the most basic anxiety of release verification — which build am I actually holding? From there, add menu items as real needs appear, and within a few weeks you will find you have grown a verification rig shaped exactly like your app.
If you have ever kept a release-build-only bug company past midnight, I hope this design shortens that night to an afternoon.
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.