●RORKMAX — Rork Max generates pure Swift instead of React Native, enabling true native apps across iPhone, iPad, Watch, TV, Vision Pro, and iMessage●APPLE — Rork's 2026 direction has a clear theme of native empowerment across the Apple ecosystem●EXPO — Standard builds run on React Native and Expo, so you're left with a real project structure and code you can keep working on●FUNDING — Rork recently raised $15M and now sees over 743,000 monthly visits with 85% growth●PRICING — Rork is free to start, with paid plans from $25/month and Rork Max at $200/month●CROSS — Rork builds iOS, Android, and web from a single prompt, finished off with a bit of follow-up tweaking●RORKMAX — Rork Max generates pure Swift instead of React Native, enabling true native apps across iPhone, iPad, Watch, TV, Vision Pro, and iMessage●APPLE — Rork's 2026 direction has a clear theme of native empowerment across the Apple ecosystem●EXPO — Standard builds run on React Native and Expo, so you're left with a real project structure and code you can keep working on●FUNDING — Rork recently raised $15M and now sees over 743,000 monthly visits with 85% growth●PRICING — Rork is free to start, with paid plans from $25/month and Rork Max at $200/month●CROSS — Rork builds iOS, Android, and web from a single prompt, finished off with a bit of follow-up tweaking
How to Make Ads and Subscriptions Coexist in a Rork App: A Solo Developer's Hybrid Revenue Model
I used to think ads and subscriptions don't mix. After a year of experiments on my own apps, I learned that with the right boundary design, a hybrid produces nearly 1.7x the revenue of either model alone. Here's the model I'm shipping, with implementation code and real numbers.
For a long time, I was skeptical of putting both ads and subscriptions into the same app. The reasoning seemed obvious: "if you've already annoyed users with ads, layering a subscription on top just speeds up the exit."
After a year of experiments on my own apps, I changed my mind. With the right boundary design, ads and subscriptions don't fight — and the combined monthly revenue ran roughly 1.7x what either model produced alone. Most of the apps I'm building with Rork right now use a hybrid configuration.
This piece walks through the hybrid revenue model I actually run as a solo developer, with the design intent behind each decision and the implementation I hand-add on top of the React Native code Rork generates. Ad gating and subscription-state synchronization sit just outside what Rork writes for you automatically — and getting that seam right is, in my experience, what separates a hybrid that earns from one that annoys.
"Ads or subscription" is the wrong frame
The "ads or subscription" debate only makes sense if you treat all your users as one group. Real app users vary wildly across willingness to pay, frequency of use, and tolerance for ads.
Heavy ad tolerance, "I will never pay" users. Hate ads but won't pay a few hundred yen a month. Will pay monthly but balk at annual. Will buy annual outright. Will pay once for a lifetime unlock and nothing after.
I can clearly see at least these five segments in my own apps. Picking one plan means abandoning four of them. The hybrid model is a direct response to that segmentation.
Here are the real numbers from one of my wallpaper apps. It's a single example from a single app, but it's a useful starting point for reasoning about whether the design holds up.
User segment
Share
Monthly revenue per user (rough)
Contribution to total
Non-paying (ads only)
~80%
10–25 JPY
~35% of total
Subscribers
~8%
280–480 JPY
~50% of total
One-time buyers
~4%
~120 JPY equivalent, first month only
~8% of total
Active on ads only
~8%
15–30 JPY
~7% of total
What I most want this table to say is that half the revenue comes from the 8% who subscribe, and the other half from the thin, steady accumulation across the non-paying base. Optimize for only one and your design throws the other away. The hybrid exists to catch both at once.
Don't draw the boundary along features
The classic mistake is to split by feature: "Feature A is free with ads, Feature B is subscription only." From the user's view, this reads as "you keep adding things I can't use unless I pay."
I draw the boundary on two axes: frequency and quality.
Frequency-wise, an example: "save up to three wallpapers per day for free, beyond the fourth either watch a rewarded ad or upgrade." The feature itself is open to everyone — only the cap is paywalled, with two ways to lift it. The ad-tolerant and the subscription-friendly each have a home.
Quality-wise: "free is standard resolution, subscription is 4K." This isn't taking a feature away — it's offering an upgrade for those who want higher quality.
When you cut along these two axes, users rarely feel funneled. In my data, conversion to subscription is noticeably higher than the feature-split approach.
Implementing the daily frequency cap
A frequency boundary that exists only as a concept falls apart in the code. Here's the minimal daily counter I run in React Native (the shape Rork generates). It resets when the local date changes, and once the free cap is exceeded it reports whether the unlock path is "ad" or "subscription."
// useDailyQuota.ts — minimal management of daily free usageimport AsyncStorage from "@react-native-async-storage/async-storage";const FREE_DAILY_LIMIT = 3;function todayKey() { // Slice the day on the device-local date (slicing on UTC drifts at night) const d = new Date(); return `quota:${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;}export async function getUsedCount(): Promise<number> { const raw = await AsyncStorage.getItem(todayKey()); return raw ? parseInt(raw, 10) : 0;}export async function consumeOne(): Promise<void> { const key = todayKey(); const used = await getUsedCount(); await AsyncStorage.setItem(key, String(used + 1));}// Report whether the cap is exceeded, and if so whether "ad" or "subscription" unlocks itexport async function checkQuota(isSubscriber: boolean) { if (isSubscriber) return { blocked: false, unlockBy: "subscription" as const }; const used = await getUsedCount(); if (used < FREE_DAILY_LIMIT) return { blocked: false, unlockBy: "free" as const }; return { blocked: true, unlockBy: "ad" as const };}
What I deliberately avoid here is holding the count server-side. In the early stage of solo development, device-local works fine. Routing through a server means a network call on every launch, and a user who hits the wall offline gets confused. Start with AsyncStorage, and add server validation only once abuse becomes a problem you can't ignore — that's the order I recommend.
Why slice the date on the device? Because slicing on UTC resets the count at 9 p.m. for users in Japan. I actually shipped this once — "the cap comes back early, but only at night" — and only noticed when a review pointed it out. Small thing, but it goes straight to the trustworthiness of your frequency design.
✦
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
✦Drawing the ads-vs-subscription boundary along frequency and quality so users never feel funneled, with the implementation behind it
✦Startup state-synchronization code that prevents the classic accident of loading ads before the subscription check resolves and showing ads to paying users
✦The two-layer structure that earns revenue from the 80 percent who never pay, plus day-by-day offer logic that targets the first-30-day conversion window
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.
Ads and subscriptions clash hardest when ads are wrecking the experience. An app that fires a full-screen video on launch and a popup on every navigation reduces the value of subscription to "the ads will go away." That's a depressingly small upgrade story.
My placement rules for Rork apps look roughly like this. Banner ads are minimized — never permanently anchored to the bottom; instead, they appear at list endings or session breaks. Interstitials only fire after a user finishes a task, never during one. Rewarded ads only appear when a user opts in to "watch this to unlock something."
With this placement, raw frustration with ads stays low. The result is that subscription stops being "an emergency exit from the ads" and starts being "an upgrade for a smoother experience."
Unlocking the cap with a rewarded ad
The spot where you tell a capped-out user "watch an ad to unlock one more" is the heart of the hybrid design. In the rewarded-ad callback, you rewind the counter only when the reward is actually granted. The crucial line is: don't unlock for users who quit watching partway.
// unlockByRewardedAd.tsimport { RewardedAd, RewardedAdEventType, TestIds } from "react-native-google-mobile-ads";import AsyncStorage from "@react-native-async-storage/async-storage";// Replace with your own ad unit ID in production (use TestIds.REWARDED while testing)const AD_UNIT_ID = __DEV__ ? TestIds.REWARDED : "YOUR_REWARDED_AD_UNIT_ID";export function unlockByRewardedAd(onUnlocked: () => void) { const rewarded = RewardedAd.createForAdRequest(AD_UNIT_ID, { requestNonPersonalizedAdsOnly: true, // toggle based on consent state }); const loaded = rewarded.addAdEventListener(RewardedAdEventType.LOADED, () => { rewarded.show(); }); // Roll back one "used" count ONLY when the reward is granted const earned = rewarded.addAdEventListener(RewardedAdEventType.EARNED_REWARD, async () => { const key = todayKeyFromUseDailyQuota(); // must match useDailyQuota's todayKey exactly const used = parseInt((await AsyncStorage.getItem(key)) ?? "0", 10); await AsyncStorage.setItem(key, String(Math.max(0, used - 1))); onUnlocked(); }); rewarded.load(); return () => { loaded(); earned(); };}
Never unlock on any event other than EARNED_REWARD (closed, failed). If you cut a corner here and "unlock when the ad is shown," a user who closes the ad in one second still gets the unlock, and your rewarded-ad revenue collapses. I made exactly this mistake at first, watched eCPM sink to less than half of what I expected, and burned two full days chasing the cause.
The order of subscription check vs. ad load — the pitfall I actually shipped
This is the implementation I most want you to take away from this article.
In an early implementation, paying subscribers were briefly seeing ads at app launch. Root cause: the AdMob "should we show ads?" flag and the subscription state check weren't synchronized at startup. For the first second or two, ad load fired before the subscription check completed, and that's enough to display an ad to a paying user.
The fix was to deliberately delay ad load until after subscription state is confirmed. A 1–2 second load delay is acceptable. Showing ads to a paying user is never acceptable. This is exactly the kind of trap a fast-moving environment like Rork makes easy to fall into — design the state coordination carefully on day one.
In code, I anchor on the promise that resolves subscription state, and hold ad initialization until it confirms "non-subscriber."
// adGate.ts — initialize ads only after subscription state is confirmedimport mobileAds from "react-native-google-mobile-ads";import { getSubscriptionState } from "./subscription"; // RevenueCat-style wrapperlet adsReady = false;export async function initAdsAfterSubscriptionCheck() { // Wait for a CONFIRMED value here. On timeout, do NOT fall back to "non_subscriber" let state: "subscriber" | "non_subscriber" | "unknown" = "unknown"; try { state = await Promise.race([ getSubscriptionState(), new Promise<"unknown">((r) => setTimeout(() => r("unknown"), 4000)), ]); } catch { state = "unknown"; } // For subscribers, OR when "unknown," do not initialize ads (when in doubt, don't show) if (state === "subscriber" || state === "unknown") { adsReady = false; return; } await mobileAds().initialize(); adsReady = true;}export function canShowAds() { return adsReady;}
The crux of this design is the single rule that you do not show ads when the state is "unknown." If you fall back to non_subscriber on a slow network or a timeout, that's precisely when you show ads to a paying user. Losing one ad impression costs far less than breaking one paying user's trust. Make "when in doubt, don't show" non-negotiable.
The first 30 days decide subscription conversion
To improve subscription conversion in a hybrid setup, the first 30 days of user experience are decisive. I anchor on three things.
For the first three days, I deliberately reduce ads. Burning out a brand-new user with ads while they're still learning the app is a long-term loss.
Around day 7, I design the free-tier frequency cap to land. Earlier than that and users hit a wall before they appreciate the value. Later than that and habit forms before they ever consider paying. Day 7 is the highest-converting window in my apps.
Around day 14, I show a limited-time offer. Something like "50% off the first month." This nudges users who are still active and weighing it. If they survived 14 days, they're likely to stick around — that's exactly when an offer earns its keep.
Past 30 days, instead of pushing subscription harder, I offer alternatives — a one-time purchase, "ads only" mode, etc. Forcing the subscription path beyond this point loses opportunities.
This routing branches cleanly on days-since-install. I use a function that returns "which offer should I show right now?" from the difference against the install date saved on first launch.
// offerStage.ts — route offers by days since installimport AsyncStorage from "@react-native-async-storage/async-storage";export async function ensureInstallDate() { const existing = await AsyncStorage.getItem("install_date"); if (!existing) await AsyncStorage.setItem("install_date", String(Date.now()));}export async function currentOffer(isSubscriber: boolean) { if (isSubscriber) return "none"; const raw = await AsyncStorage.getItem("install_date"); const installed = raw ? parseInt(raw, 10) : Date.now(); const days = Math.floor((Date.now() - installed) / 86_400_000); if (days < 3) return "quiet"; // fewer ads, no offer if (days < 7) return "soft_hint"; // gently mention the subscription exists if (days < 14) return "cap_reached"; // formally propose in the cap context if (days < 30) return "discount_50"; // limited 50%-off first month return "alt_oneshot"; // steer toward one-time / ads-only}
You don't need to engineer the perfect design on day one. Even a two-way split of days < 14 and days >= 14 meaningfully improves the timing of your subscription prompt. I started with two branches myself and added stages as the numbers came in.
Treat the "won't pay" segment with care
The hybrid model's quiet superpower is that even non-paying users still produce revenue.
As the distribution table at the top showed, non-paying users make up about 80% of my apps. If you optimize purely for subscription, your design throws that 80% away. A two-layer structure — small revenue per non-paying user via ads, large revenue per subscriber — produces remarkably stable monthly totals.
So I keep the non-paying experience clean: ads stay minimal, but the daily frequency cap stays in place. Those users come back the next day, see another small batch of ads, and the volume adds up across the user base.
What twelve years of running AdMob has convinced me of is this: apps that treat the non-paying segment carelessly see their review scores slide over time, and that eventually narrows the funnel into the subscription tier too. Free users are also "the pool of your future paying users." Protecting their experience is, in a roundabout way, protecting your subscription revenue.
Closing — your minimum first move
If you currently run an ads-only app, just add a daily frequency cap. Adding a single structure like this article's useDailyQuota.ts gets you to the doorstep of a hybrid design.
Watch conversion and churn for one month. Once you have data, decide whether to formally introduce subscription. Trying to ship a perfect hybrid on day one tends to break the design. Layer one piece at a time and let user behavior guide the next move.
I hope this gives fellow solo developers a starting point for your own design. Thank you 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.