◉RORK LABJP
●EXPO — EAS Observe now records native crashes (Oct 7). On SDK 57, update to 57.0.21 or later●RORK — GPT-6.1 Sol (Sep 29) and Claude Sonnet 5.5 (Sep 28) are now in the model menu●RN 0.88 — The stable release was still unpublished on its Oct 12 target. rc.4 (Oct 7) is the newest; 0.87.1 is Latest●Q&A — Readers report expo-widgets showing a black widget in production builds only●WINDOWS — A walkthrough shows how to ship an iPhone app from Windows only, with no Mac●NEW — Three checks to run first when notifications stop arriving in Expo Go●EXPO — EAS Observe now records native crashes (Oct 7). On SDK 57, update to 57.0.21 or later●RORK — GPT-6.1 Sol (Sep 29) and Claude Sonnet 5.5 (Sep 28) are now in the model menu●RN 0.88 — The stable release was still unpublished on its Oct 12 target. rc.4 (Oct 7) is the newest; 0.87.1 is Latest●Q&A — Readers report expo-widgets showing a black widget in production builds only●WINDOWS — A walkthrough shows how to ship an iPhone app from Windows only, with no Mac●NEW — Three checks to run first when notifications stop arriving in Expo Go
Articles/Business
⟐ Business/2026-04-26Advanced

Taking a Rork Max Native App Up the App Store Charts — The Order I Build Ads, StoreKit 2, and Subscriptions

The order I follow to grow a Rork Max SwiftUI app into one that stays near the top of its App Store category: a single AdGate that decides when ads may show, StoreKit 2 Transaction.updates monitoring, and where I draw the lines on ASO and retention — with working code.

Rork578Rork Max235App Store90AdMob71IAP6Subscription23ASO27Indie Dev39Monetization38

One Monday morning I opened my reviews and stopped scrolling. Two one-star ratings sat side by side, both saying the same thing: "I subscribed and I'm still seeing ads."

The cause took about ten minutes to find. The "should we show an ad?" check lived in two places — once next to the banner, once next to the interstitial — and only one of them looked at subscription status. The code ran fine. My tests never caught it.

Rork Max has made building a SwiftUI native app remarkably light. What hasn't gotten lighter is the step from "it runs" to "it stays near the top of its category and pays every month." Here's how I split that step into design, implementation, and operations, and the order I currently follow as an indie developer.

Three Lines I Write Before Generating Any Code

When I look at apps that hold a high spot in their category for a long time, three things show up again and again: value you can feel within minutes of launch, a reason to come back daily or weekly, and a store listing that makes the purpose obvious at a glance.

Early on I skipped this and started from features. I ended up with several apps that were feature-complete and simply not reopened. Now, before I ask Rork Max for anything, I write three lines:

  • Core experience — one sentence describing the "this is useful" moment in the first few minutes
  • Return trigger — one reason to come back (a notification, accumulating records, something new)
  • Monetization points — where ads go, and where a one-time purchase or subscription is offered

Rork Max turns the first two into code quickly. The third is about words and images, and front-loading time there pays off later.

A Project Structure That Keeps Monetization in One Place

I reshape what Rork Max generates into this layout before growing it:

MyApp/
├── App/
│   └── MyAppApp.swift
├── Features/
│   ├── Home/
│   ├── Detail/
│   └── Settings/
├── Monetization/
│   ├── AdGate.swift          // the single place that decides whether ads may show
│   ├── AdManager.swift
│   ├── EntitlementStore.swift // one-time purchase and subscription rights
│   └── Paywall/
└── Resources/
    └── Localizable.xcstrings

The review incident happened because ad, purchase, and subscription checks were scattered across screens. Whether an ad may appear gets decided in exactly one place in the app — that's the line I hold now, and I haven't seen that particular one-star review since.

One Owner for Entitlements with StoreKit 2

Before deciding on ads, the app needs a reliable answer to "does this user have the right to be ad-free?" I keep both the one-time "Remove Ads" purchase and the subscription in a single store.

// EntitlementStore.swift — one owner for one-time and subscription entitlements (StoreKit 2)
import StoreKit
 
@MainActor
final class EntitlementStore: ObservableObject {
    static let shared = EntitlementStore()
 
    @Published private(set) var hasRemoveAds = false
    @Published private(set) var isSubscribed = false
    @Published private(set) var products: [Product] = []
 
    private let removeAdsID = "com.example.myapp.removeads"
    private let subscriptionIDs: Set<String> = [
        "com.example.myapp.monthly",
        "com.example.myapp.yearly"
    ]
    private var updatesTask: Task<Void, Never>?
 
    /// Whether the user is ad-free (the only thing AdGate reads)
    var isAdFree: Bool { hasRemoveAds || isSubscribed }
 
    init() {
        // Catch changes that happen outside the purchase screen (renewals, refunds, Family Sharing, Ask to Buy approvals)
        updatesTask = Task { [weak self] in
            for await result in Transaction.updates {
                if case .verified(let transaction) = result {
                    await transaction.finish()
                }
                await self?.refresh()
            }
        }
    }
 
    deinit { updatesTask?.cancel() }
 
    func loadProducts() async {
        do {
            products = try await Product.products(for: [removeAdsID] + Array(subscriptionIDs))
        } catch {
            print("loadProducts failed: \(error)")
        }
        await refresh()
    }
 
    /// Call at launch and when returning to the foreground
    func refresh() async {
        var removeAds = false
        var subscribed = false
        for await result in Transaction.currentEntitlements {
            guard case .verified(let transaction) = result,
                  transaction.revocationDate == nil else { continue }
            if transaction.productID == removeAdsID { removeAds = true }
            if subscriptionIDs.contains(transaction.productID) { subscribed = true }
        }
        hasRemoveAds = removeAds
        isSubscribed = subscribed
    }
 
    func purchase(_ product: Product) async -> Bool {
        do {
            guard case .success(let verification) = try await product.purchase(),
                  case .verified(let transaction) = verification else { return false }
            await transaction.finish()
            await refresh()
            return true
        } catch {
            print("purchase failed: \(error)")
            return false
        }
    }
}

My earlier version didn't listen to Transaction.updates. Changes that never pass through your purchase screen — auto-renewals, refunds, a purchase approved later through Ask to Buy — won't show up until the next time the user opens that screen.

The class is also marked @MainActor. Writing @Published values from a background task makes SwiftUI updates flaky; that one line ended a symptom where the entitlement badge stayed stale for a moment.

Only AdGate Decides Whether an Ad Appears

This is the part I rebuilt after that Monday. Alongside the entitlement check, the same place also asks: did the user just finish something, and has enough time passed since the last ad?

// AdGate.swift — the only place that decides whether an interstitial may show
import Foundation
 
@MainActor
final class AdGate {
    static let shared = AdGate()
 
    private let entitlements = EntitlementStore.shared
    private let minInterval: TimeInterval = 180    // minimum seconds since the last one
    private let actionsPerAd = 3                   // at most one per N completed actions
    private let graceLaunches = 2                  // no ads for the first few launches
 
    private var completedActions = 0
    private var lastShownAt: Date?
 
    private var launchCount: Int {
        UserDefaults.standard.integer(forKey: "launchCount")
    }
 
    /// Call right after a natural break (saved, finished reading)
    func shouldShowInterstitial(now: Date = .now) -> Bool {
        if entitlements.isAdFree { return false }        // entitlement check always comes first
        if launchCount <= graceLaunches { return false }
 
        completedActions += 1
        guard completedActions >= actionsPerAd else { return false }
        if let last = lastShownAt, now.timeIntervalSince(last) < minInterval { return false }
 
        completedActions = 0
        lastShownAt = now
        return true
    }
 
    /// Banners go through the same gate
    var shouldShowBanner: Bool { !entitlements.isAdFree }
}

The 180 and 3 are just my starting points; they move a lot depending on the app, so please tune them against your own numbers. What matters more than the values is that the decision exists in one place.

AdMob in SwiftUI Sits Behind AdGate

AdMob only loads and presents. The code below uses the Swift names from Google Mobile Ads SDK 12 and later (no GAD prefix). If you're on 11 or earlier, read them as GADInterstitialAd and friends.

// AdManager.swift — loading and presenting only (AdGate decides)
import GoogleMobileAds
import SwiftUI
 
@MainActor
final class AdManager: NSObject, ObservableObject, FullScreenContentDelegate {
    static let shared = AdManager()
    @Published private(set) var isInterstitialReady = false
    private var interstitial: InterstitialAd?
    private let unitID = "ca-app-pub-XXXXXXXXXXXXXXXX/YYYYYYYYYY"   // replace with your production ID
 
    func start() {
        MobileAds.shared.start()
        load()
    }
 
    private func load() {
        InterstitialAd.load(with: unitID, request: Request()) { [weak self] ad, error in
            Task { @MainActor in
                guard let self else { return }
                if let error {
                    print("interstitial load failed: \(error.localizedDescription)")
                    self.isInterstitialReady = false
                    return
                }
                self.interstitial = ad
                self.interstitial?.fullScreenContentDelegate = self
                self.isInterstitialReady = true
            }
        }
    }
 
    /// Always routed through AdGate
    func presentIfAllowed(from root: UIViewController) {
        guard AdGate.shared.shouldShowInterstitial(),
              let ad = interstitial else { return }
        ad.present(from: root)
    }
 
    // preload the next one after dismissal
    nonisolated func adDidDismissFullScreenContent(_ ad: FullScreenPresentingAd) {
        Task { @MainActor in
            self.isInterstitialReady = false
            self.load()
        }
    }
}
 
struct BannerSlot: View {
    let unitID: String
    @ObservedObject private var entitlements = EntitlementStore.shared
 
    var body: some View {
        if AdGate.shared.shouldShowBanner {
            BannerAdView(unitID: unitID)
                .frame(height: 50)
        }
    }
}
 
struct BannerAdView: UIViewRepresentable {
    let unitID: String
 
    func makeUIView(context: Context) -> BannerView {
        let banner = BannerView(adSize: AdSizeBanner)
        banner.adUnitID = unitID
        banner.rootViewController = UIApplication.shared.connectedScenes
            .compactMap { ($0 as? UIWindowScene)?.keyWindow?.rootViewController }
            .first
        banner.load(Request())
        return banner
    }
 
    func updateUIView(_ uiView: BannerView, context: Context) {}
}

I used to preload the next interstitial right after presenting. Now I wait for adDidDismissFullScreenContent. Loading while an ad was still on screen occasionally left me handling the same ad object twice.

Because BannerSlot observes EntitlementStore, the banner area disappears the instant a purchase completes. A few seconds of "I paid and nothing changed" is probably more unsettling for users than we developers tend to assume.

Subscriptions Assume the Free Tier Is Genuinely Enough

Designs that lock the app behind a subscription seem to invite low ratings. The line I keep: the main use case works completely for free, and the subscription adds depth for people who want more.

Things I've put behind a subscription:

  • Ad removal (kept alongside the one-time purchase)
  • A longer history of saved records
  • Themes and advanced filters
  • Early access to new features

If you offer both the one-time "Remove Ads" and a subscription, isAdFree has to consider both. That's the reason EntitlementStore exists.

ASO and the First Handful of Ratings

Search visibility in the App Store leans heavily on the title, subtitle, and keyword field. Here's how I currently fill them:

FieldLimitHow I write it
Title30 charactersApp name plus a word for what it does
Subtitle30 charactersWhose task, which task, and how it changes
Keywords100 charactersComma-separated compound terms that don't repeat the title

Chasing only the biggest terms buries you under competitors. For indie apps, a few compound terms with moderate popularity in Apple Search Ads and few strong incumbents have worked better for me.

For the first ratings, I combine three things:

  • Build-in-public posts on note and X that focus on decisions and trade-offs, not feature lists
  • A limited preview in a community at roughly 70% completion
  • Direct invitations to people I know, plus a restrained SKStoreReviewController call

Rating prompts follow the same rule as AdGate: only right after a natural break. An ad and a rating prompt landing in the same moment make both feel worse.

Retention Targets and Notification Copy

For my own apps I watch roughly 40% on Day 1, 20% on Day 7, and 10% on Day 30. These aren't industry benchmarks — they're the lines where, if I fall below, I revisit the return trigger.

What moved retention most were notifications and visible progress. UNUserNotificationCenter works fine from SwiftUI. Copy tied to the user's own records ("Picking up where yesterday's entry left off?") felt far more effective than a generic "Come back tomorrow."

Pitfalls I Walked Into

1. Too many ad formats right after launch. Running banners only for the first few weeks, then adding interstitials once ratings settle, has been safer. Setting a generous graceLaunches in AdGate helps too.

2. Writing the ad decision per screen. That's the Monday story. Once the check lives in two places, one of them will miss the entitlement.

3. Not listening to Transaction.updates. Entitlement changes that bypass the purchase screen won't reflect until the next launch. It's safer to assume renewals and refunds happen outside your app.

What to Do in the Next 30 Minutes

Grep your project for where ads are shown. Just count how many places call present( and create a BannerView. If either number is two or more, that's your first candidate to fold into an AdGate.

If you'd like to settle the pricing side first, Making Rork Max Pay for Itself with SwiftUI Native Apps covers the groundwork. For deeper subscription pricing and retention work, Building a Rork App That Earns $1,000/Month picks up where this leaves off.

Now that building has become light, choosing carefully where each decision lives has quietly become the center of my work.

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 $15 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

⟐ Business2026-05-03
Making a Rork App Sell on the App Store — ASO, Pricing, and IAP Strategy
How to take a Rork-built app from invisible to ranked, priced right, and earning real money on the App Store. ASO, price psychology, IAP design, and subscription tactics drawn from running indie apps solo.
⟐ Business2026-05-02
A Solo Developer's Strategy for Pushing a Mobile App Past ¥100k/Month — Real Revenue Tuning Tested on Rork-Built Apps
The hands-on strategy for breaking the ¥100k/month wall in solo mobile development. Ad eCPM optimization, IAP tuning, subscription retention, and ASO — every lever I've actually tested on Rork-built apps over 12 years of solo monetization, with code and numbers.
⟐ Business2026-04-09
How to Get Your Rork App Featured on the App Store — 7 Design Strategies That Impress Apple's Editorial Team
How to design, build, and nominate your Rork app for App Store featuring — Apple HIG compliance, accessibility, performance, and a field-by-field walkthrough of Featuring Nominations in App Store Connect.
📚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