●PRICE — Rork Max spans $200 to $1,800 per month, with the upper tiers aimed at heavier builders and teams●FREE — The free tier lands at roughly five prompts per week, enough to try it but not to build on continuously●SHIP — App Store publishing is automated through builds, certificates, and submission, so you can ship an iOS app without a Mac or Xcode●SIM — A browser-streamed simulator lets you watch your app run in a real Apple environment from your own browser●NATIVE — It reaches HealthKit, ARKit and LiDAR, NFC, Dynamic Island, and Metal 3D — territory React Native cannot touch●FUNDING — Rork raised a $15M seed led by Left Lane Capital, announced April 9, 2026, and acquired app builder Paperline●PRICE — Rork Max spans $200 to $1,800 per month, with the upper tiers aimed at heavier builders and teams●FREE — The free tier lands at roughly five prompts per week, enough to try it but not to build on continuously●SHIP — App Store publishing is automated through builds, certificates, and submission, so you can ship an iOS app without a Mac or Xcode●SIM — A browser-streamed simulator lets you watch your app run in a real Apple environment from your own browser●NATIVE — It reaches HealthKit, ARKit and LiDAR, NFC, Dynamic Island, and Metal 3D — territory React Native cannot touch●FUNDING — Rork raised a $15M seed led by Left Lane Capital, announced April 9, 2026, and acquired app builder Paperline
Rork App Revenue Design: Where Ads and Subscriptions Eat Each Other
How to layer AdMob, subscriptions, and one-time purchases in a Rork app without letting them cannibalize each other. Covers value-density placement, entitlement-first RevenueCat architecture, server-side receipt verification, and behavior-triggered paywalls.
The week I increased ad frequency, ad revenue went up. Two weeks later, new subscription signups dropped noticeably.
Net effect: roughly nothing.
Laying the numbers side by side finally made it click. Ads and subscriptions aren't two separate revenue streams. They compete for the same moment in the same user's day. Push one, and the other quietly steps back.
Revenue design isn't about picking a model. It's about finding where the two collide, and drawing a line there before you ship.
Rork gives you an app skeleton in under an hour. That speed is exactly why the line matters — you'll be building on that skeleton for months.
You Don't Pick a Model. You Layer Them.
As long as you're thinking "ads or subscriptions," you're running on one lung. Apps that actually earn almost always layer multiple models.
To layer them well, you need to be clear about who each model takes from, and what they give up in return.
Model
Who pays
What they trade
Works when
Fails when
Ads (AdMob)
The non-paying majority
Attention and time
High launch frequency, short sessions
Focus-heavy productivity apps
Subscription
A committed minority
Ongoing value
Content grows, sync exists
One-and-done utilities
One-time purchase
People with one specific gripe
Removal of that gripe
Ad removal or feature unlock is obvious
You need recurring revenue
What the table reveals is that the three models address different people. Ads monetize those who will never pay. Subscriptions monetize those who stay. One-time purchases monetize those who want one annoyance gone, right now.
Layering, then, means never mixing those three groups.
What I keep confirming in my own indie work: aim two models at the same user simultaneously and both underperform. An app that shows you ads while also pressing you to subscribe loses on both counts.
Don't Let One-Time Purchases Collapse Into "Remove Ads"
"Remove ads — $1.99" sells. It's also your subscription's worst enemy.
Someone who pays $1.99 once will never enter your $1.99/month premium tier. The annoyance that would have driven them there no longer exists.
If you ship a one-time purchase, make it a different axis of value, not a cheaper subscription. Fold ad removal into the subscription's benefits, and reserve one-time purchases for things that don't compete with recurring value — buying a single content pack outright, for instance.
Rearranging this later is genuinely painful, because existing purchasers hold rights you can't revoke. Decide it while the skeleton is still warm.
Find the Collision With "Value Density"
So where does the line go?
I think about it as value density: how concentrated is the value the user is receiving on this screen, per unit of time?
Low value density (scrolling a list, browsing categories) → where ads belong
High value density (they found the wallpaper they wanted, they finished writing an entry) → where the paywall belongs
Swap those two and both break.
Interrupt a high-density moment with an ad and the user reads it as "you cut in right at the best part." That user is no longer a subscription candidate.
Show a paywall during a low-density moment and it reads as "you're asking for money before I've seen what this is worth." That doesn't convert either.
Put differently: ads can burn down your subscription pipeline. Miss that, crank up ad frequency, and you're trading LTV for short-term eCPM.
Implementing the Guard
In code, the ad decision needs to know what moment the user is in.
// Guard that keeps ads out of high value-density momentstype Moment = | "browsing" // list / category selection (low) | "previewing" // examining a detail view (medium) | "acquired" // download or save just completed (high) | "returning"; // heading back to the list afterward (low)const AD_ALLOWED: Record<Moment, boolean> = { browsing: false, // don't interrupt exploration previewing: false, // don't interrupt evaluation acquired: false, // this seat is reserved for the paywall. Never an ad. returning: true, // only once the payoff has been felt};function canShowInterstitial(moment: Moment, isPremium: boolean) { if (isPremium) return false; return AD_ALLOWED[moment];}
Setting acquired to false is the whole point. Most apps fire their interstitial right after a download completes. On a revenue-per-impression basis it looks like the best slot available.
It's also the instant the user is most satisfied — which is to say, the instant a paywall would land. Put an ad there and the seat is taken.
Shift one step to returning and you keep the afterglow intact while still showing the ad. In my own apps, making that shift held ad revenue roughly flat while paywall conversion moved up.
✦
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
✦Identify the exact point where ads and subscriptions compete for the same user, and learn the value-density rule for drawing the line between them
✦Build RevenueCat entitlement-first with production-ready code, plus a webhook setup that moves receipt verification server-side
✦Understand why paywall conversion is driven by timing rather than copy, and implement behavior-signal gating that proves it
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.
Once the line is decided, hand the design to Rork — not just "add monetization." Ask vaguely and you'll get a skeleton where ads and paywalls share a screen.
Build a wallpaper app. Follow this revenue design strictly.
[User tiers]
- Free: ads, standard resolution, limited categories
- Premium ($1.99/mo, $11.99/yr): no ads, 4K, all categories, sync
[Ad placement constraints — IMPORTANT]
- No ads on the list screen, detail screen, or download-complete screen
- Interstitials fire ONLY on the transition from download-complete back to the list
- Banner appears only at the bottom of the list screen
[Paywall constraints — IMPORTANT]
- Paywall appears only on the download-complete screen
- Never on the first download (3rd acquisition onward)
[Stack]
React Native + Expo / RevenueCat / react-native-google-mobile-ads
Entitlement ID is "premium". Ad visibility must derive from that entitlement and nothing else.
Mark the constraints as IMPORTANT and Rork bakes the placement rules into component boundaries. Generate once with the vague prompt and once with this one, then read both — the difference is obvious.
After generation, check one thing: does ad visibility flow from a single entitlement source? If isPremium is scattered as local state across screens, restore-purchase will leak somewhere. It always does.
Build RevenueCat Entitlement-First
Treat RevenueCat as "the subscription purchase SDK" and it will break on you later — when you add a plan, run a campaign, or introduce a one-time purchase. Conditionals metastasize through the app.
The right shape: the app reads entitlements only. The mapping from plans to entitlements lives in the RevenueCat dashboard.
// The single source of truth for the whole appimport Purchases, { CustomerInfo } from "react-native-purchases";const ENTITLEMENT_ID = "premium";export function useEntitlement() { const [isPremium, setIsPremium] = useState(false); const [ready, setReady] = useState(false); useEffect(() => { const apply = (info: CustomerInfo) => { // Never inspect plan names (monthly / annual / lifetime / campaign). // Which plan granted the right is the dashboard's concern, not the app's. setIsPremium(info.entitlements.active[ENTITLEMENT_ID] !== undefined); setReady(true); }; Purchases.getCustomerInfo().then(apply).catch(() => setReady(true)); Purchases.addCustomerInfoUpdateListener(apply); }, []); // Returning `ready` matters. Render ads before resolution and a paying // subscriber sees an ad flash by — which lands in your reviews. return { isPremium, ready };}
On why ready exists:
getCustomerInfo() hits the network, so entitlements are unresolved for the first few hundred milliseconds after launch. During that window isPremium is false. Render your banner there and a paying subscriber gets a flash of advertising.
A flash is enough. To that user, the experience is "I paid and still got ads," and that's what the review will say. Hold the ad slot empty until resolution completes.
Put Restore at Launch, Not Behind a Button
Apple requires a restore button. But designing around the button loses users. Someone who just switched phones will not go spelunking through your settings screen.
// Attempt a silent restore at launch (no UI)async function silentRestore() { try { const info = await Purchases.restorePurchases(); return info.entitlements.active[ENTITLEMENT_ID] !== undefined; } catch { // Fail quietly. This is not a moment to show the user an error. return false; }}
Keep the button for review compliance, but do the actual restoring at launch. The user just experiences "it worked on my new phone." That's the correct experience.
Move Receipt Verification Server-Side
Never unlock server-side capability based on the client's customerInfo. It's trivially forgeable.
Consume RevenueCat's webhook and record entitlements on your side.
// Webhook receiver on Cloudflare Workersexport default { async fetch(request: Request, env: Env) { const auth = request.headers.get("Authorization"); if (auth !== `Bearer ${env.REVENUECAT_WEBHOOK_SECRET}`) { return new Response("unauthorized", { status: 401 }); } const { event } = await request.json(); // Only handle events that grant or revoke rights const GRANT = ["INITIAL_PURCHASE", "RENEWAL", "UNCANCELLATION", "PRODUCT_CHANGE"]; const REVOKE = ["EXPIRATION", "BILLING_ISSUE"]; const key = `entitlement:${event.app_user_id}`; if (GRANT.includes(event.type)) { await env.KV.put(key, JSON.stringify({ active: true, expires_at: event.expiration_at_ms, }), { expirationTtl: 60 * 60 * 24 * 40 }); } else if (REVOKE.includes(event.type)) { await env.KV.delete(key); } // Return 200 even for unknown events. Returning 500 makes RevenueCat // retry indefinitely, and the webhook can get disabled. return new Response("ok", { status: 200 }); },};
That last comment is the practical trap. If you 500 on event types you don't recognize, retries pile up until the webhook itself is switched off. Acknowledge everything; act on what you handle.
Treating BILLING_ISSUE as immediate revocation is a judgment call. Kill features because a card temporarily failed and the recovered user may not come back. If you rely on grace periods, restrict revocation to EXPIRATION alone.
Ad Revenue Turns on Spacing, Not Placement
Frequency capping implemented as "1 in every N actions" is common — and it punishes your most engaged users.
One-in-five means a user who taps five times in ten seconds gets a full-screen ad every ten seconds.
// Gate on both count AND elapsed timeconst MIN_ACTIONS = 5;const MIN_INTERVAL_MS = 120_000; // at least 2 minutes apartconst COLD_START_GRACE_MS = 90_000; // nothing in the first 90 secondslet actionCount = 0;let lastAdAt = 0;const launchedAt = Date.now();function shouldShowInterstitial(moment: Moment, isPremium: boolean): boolean { if (!canShowInterstitial(moment, isPremium)) return false; const now = Date.now(); if (now - launchedAt < COLD_START_GRACE_MS) return false; // protect first impressions if (now - lastAdAt < MIN_INTERVAL_MS) return false; if (++actionCount < MIN_ACTIONS) return false; actionCount = 0; lastAdAt = now; return true;}
COLD_START_GRACE_MS exists because the first 90 seconds after install decide whether your app survives.
A user who hits a full-screen ad on first launch deletes the app. Skipping that one impression buys you the probability that they open it again tomorrow.
When You Ask for ATT Changes Your eCPM
On iOS, App Tracking Transparency opt-in rates feed straight into eCPM, because they determine whether personalized ads can be served at all.
And opt-in rate moves a lot depending on when you show the dialog.
Fire it at launch and the user is asked to allow tracking before knowing what your app is. They decline.
import { requestTrackingPermissionsAsync } from "expo-tracking-transparency";// Ask right after they've received value, with contextasync function askTrackingAfterFirstValue() { const shown = await AsyncStorage.getItem("att_asked"); if (shown) return; // Show your own pre-permission sheet first, then hand off to the OS. // The OS dialog is one-shot: a decline can only be undone in Settings. await showPrePermissionSheet({ title: "About the ads you'll see", body: "This app stays free because of ad revenue. Showing more relevant ads is what keeps it that way.", }); await requestTrackingPermissionsAsync(); await AsyncStorage.setItem("att_asked", "1");}
The OS dialog fires exactly once. Once declined, you can't get it back unless the user opens Settings themselves. So gate it behind your own explanation and only send through the users who are inclined to say yes.
If someone taps "Not now" on your sheet, don't burn the OS dialog. You still have a next time.
Mediation Doesn't Work Just Because It's On
Running multiple ad networks against each other raises eCPM, but switching it on isn't enough.
Set eCPM floors — without a floor, cheap networks will happily fill every slot
Split networks by geography — the strongest network in Japan isn't the strongest in North America
Prune networks that never win — each additional network adds load latency, and latency costs you impressions
That third one gets missed. When someone adds eight networks and watches eCPM fall, the cause is almost always load delay eating the impressions themselves.
Paywall Conversion Is Timing, Not Copy
You can rewrite paywall copy indefinitely and barely move conversion. I've burned plenty of hours doing exactly that.
What moves is who you show it to, and when.
Define the Trigger With Behavior
type UserSignals = { sessionsCount: number; // launches itemsAcquired: number; // downloads or saves completed daysSinceInstall: number; lastPaywallAt: number | null;};const PAYWALL_COOLDOWN_MS = 1000 * 60 * 60 * 24 * 3; // 3 daysfunction shouldShowPaywall(s: UserSignals): boolean { // No track record of receiving value? No paywall. if (s.itemsAcquired < 3) return false; // One launch isn't enough to form an opinion. if (s.sessionsCount < 2) return false; // Don't re-ask someone who just declined. This drives uninstalls. if (s.lastPaywallAt && Date.now() - s.lastPaywallAt < PAYWALL_COOLDOWN_MS) { return false; } return true;}
itemsAcquired < 3 does most of the work here.
Three downloads means the user has confirmed the app is useful to them. A first-time user is still evaluating, and dropping a price tag into that evaluation just hands them a reason to stop.
The cooldown matters too. Re-showing a paywall the day after a decline generates uninstalls, not revenue. Wait three days and let them collect more value in between.
Why to Lead With Annual — and What It Costs
Price annual 30–50% below the monthly equivalent and feature it prominently. That's the standard play, and it works: churn is structurally lower, LTV climbs.
But there's a cost. Leaning on annual slows your learning rate.
With monthly, a paywall change shows up in churn within weeks. With annual, it shows up in a year. While you're still iterating on price and plan structure, keeping a meaningful share of monthly is what lets you tell what actually worked.
My own preference: show monthly plainly until the product settles, then bring annual forward once the design is stable. Going annual-only from day one locks in a design you haven't validated yet, for twelve months.
What to Measure, and What to Ignore
The list of measurable things is infinite. People who watch all of them watch none of them.
Metric
Why look at it
Decision-grade?
ARPDAU
Combined ad + subscription efficiency
Yes — this one number can green-light or kill a change
D1 / D7 retention
Detects when ads are damaging the experience
Yes — always paired with ARPDAU
Paywall shown → purchased
Whether your trigger timing is right
Mostly — you must log the shown event as the denominator
eCPM alone
Ad network health
Weak — misleading when divorced from impression count
DAU alone
Sense of scale
Weak — not usable for revenue decisions
Read ARPDAU and D7 retention as a pair. That alone adjudicates most ad experiments.
ARPDAU up, D7 down means the change borrowed against future revenue. Raise ad frequency and this is precisely the shape you'll see.
import analytics from "@react-native-firebase/analytics";// Always log paywall IMPRESSIONS. Log only purchases and you have no// denominator, which means no conversion rate.async function logPaywallShown(trigger: string, signals: UserSignals) { await analytics().logEvent("paywall_shown", { trigger, items_acquired: signals.itemsAcquired, sessions_count: signals.sessionsCount, days_since_install: signals.daysSinceInstall, });}async function logPurchase(plan: "monthly" | "annual", price: number) { await analytics().logPurchase({ currency: "USD", value: price, items: [{ item_id: `subscription_${plan}`, item_name: `${plan} subscription` }], });}
Attaching behavior signals to paywall_shown pays off later. You can answer "did triggering at 3 downloads beat triggering at 5?" analytically, without shipping a code change.
Where People Get Stuck
Paying subscribers see a flash of advertising
You're rendering the ad slot before the entitlement resolves. Use the ready flag above and hold the slot empty until resolution. This one usually reaches you via a review — meaning the star rating already dropped.
Ads don't appear in production
Check for leftover test ad unit IDs and verify the AdMob app ID in app.json. Delivery is often throttled for 24–72 hours right after store approval, so give it time before debugging further.
Purchase completes but premium doesn't unlock
Confirm the key in customerInfo.entitlements.active matches the entitlement ID in your RevenueCat dashboard exactly. Swapped sandbox and production API keys are a frequent culprit.
Subscription rejected in review
Restore purchases, cancellation instructions, and links to Terms and Privacy Policy all belong on the paywall screen itself. Bury them in a settings sub-menu and you'll be sent back.
Wrapping Up
The first move in revenue design isn't adding ad units or polishing paywall copy.
Identify the single moment in your app where the user is most satisfied.
Never put an ad there. Reserve it for the paywall. Push ads into the lower-density time on either side of it.
The line follows from that. After that it's just watching ARPDAU and D7 retention as a pair, and moving carefully.
Rork hands you the skeleton in under an hour. Spend the time it saves on drawing this line.
I'm still working this out myself — the placement I believe in today may look wrong to me in six months. But this is what I can say with confidence right now. Thanks for reading.
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.