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:
| Field | Limit | How I write it |
|---|---|---|
| Title | 30 characters | App name plus a word for what it does |
| Subtitle | 30 characters | Whose task, which task, and how it changes |
| Keywords | 100 characters | Comma-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
SKStoreReviewControllercall
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.