●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
Monetizing SwiftUI Native Apps Built with Rork Max — From App Store Approval to Subscription Revenue
How to take SwiftUI code generated by Rork Max through App Store review and add StoreKit 2 subscriptions, with working code and the items review actually checks.
Even now that Rork Max writes SwiftUI for you, the wall between shipping and getting paid sits somewhere other than the screens.
Not long ago I installed a generated project on a real device and tapped the purchase button. The sandbox purchase went through, but after I restarted the app, the paid state was gone. Nothing in the code read the purchase back as a current entitlement.
A polished screen and an app that stays paid are different things. Here is the work that closes that gap, in the order of review, billing code, pricing, flow, and retention.
Treat generated code as a good draft, and re-read the review and billing parts yourself. That is the line I draw.
What to inspect before review
Rork Max's output works well as a UI skeleton. The places where it was not written with release in mind are nearly the same every time.
Area
What tends to happen
How to check
Accessibility
Few or no accessibilityLabel values
Walk the main screens with VoiceOver
Error handling
Nothing is shown on failure
Try purchase and loading in airplane mode
Billing
No re-read of entitlements, or an old API
Buy, kill the app, relaunch
Permissions
Requested all at once on launch
Count the permission dialogs on first launch
iPad
Fixed iPhone-only layout
Open the main screens in an iPad simulator
Review feedback almost always extends one of these rows. Below are the fixes, starting with what touches billing.
Five things review checks, and how to fix them
The guidelines change. Before you implement, open the latest App Store Review Guidelines and re-read the subscription section (around 3.1.2). The order below follows what generated code most often misses.
1. Consistent privacy information
What you need is agreement between what the app collects, the privacy answers in App Store Connect, the privacy policy URL, and the Privacy Manifest (PrivacyInfo.xcprivacy). A consent screen on launch is not required in itself.
The first thing I check is what the SDKs collect. Adding an ads or analytics SDK and forgetting to update the declaration is the mismatch that happens most.
2. A way to manage or cancel subscriptions
Put an entry point inside the app that leads to subscription management. StoreKit 2 has a dedicated API, and it is more reliable than opening a URL yourself.
import StoreKitimport SwiftUIstruct ManageSubscriptionButton: View { @State private var showManage = false var body: some View { Button("Manage subscription") { showManage = true } .manageSubscriptionsSheet(isPresented: $showManage) }}
manageSubscriptionsSheet is available on iOS 15 and later. Users can cancel or change plans without leaving the app, which also makes the path easy to explain to a reviewer.
3. Error handling that does not freeze the screen
A screen that hangs on a network or billing failure hurts you in review and in ratings. Keep the failure as state and show it.
@MainActorfinal class CatalogLoader: ObservableObject { enum Phase { case idle, loading, loaded([String]), failed(String) } @Published private(set) var phase: Phase = .idle func load() async { phase = .loading do { let url = URL(string: "https://api.example.com/items")! let (data, response) = try await URLSession.shared.data(from: url) guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw URLError(.badServerResponse) } phase = .loaded(try JSONDecoder().decode([String].self, from: data)) } catch { phase = .failed("Could not load. Check your connection and try again.") } }}
My rule: do not swallow errors with print. Let the view react to failed and offer a retry button.
4. Ask for permission at the moment of use
Request camera or location just before the feature opens. Write the usage description in Info.plist in the language of the feature. Vague text such as "to improve the app" is a classic reason for being sent back.
5. How it looks on iPad
If you ship without restricting to iPhone, confirm the main screens hold together on iPad. NavigationSplitView separates a list and its detail naturally.
struct RootView: View { @State private var selection: String? private let items = ["Item 1", "Item 2", "Item 3"] var body: some View { NavigationSplitView { List(items, id: \.self, selection: $selection) { Text($0) } } detail: { Text(selection ?? "Select an item") } }}
✦
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
✦Five areas to inspect in Rork Max's SwiftUI output before review, with fixes you can paste in
✦StoreKit 2 entitlement checks, free-trial detection, and a cancellation path, implemented with verification
✦A formula for estimating revenue from your own numbers, and a realistic order for reaching your first 10 subscribers
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.
This part fixes the "gone after restart" problem. StoreKit 2 asks you to decide paid state from the current entitlements, not from the moment of purchase.
In App Store Connect, create monthly and yearly products in one subscription group. Configure a free trial as an introductory offer on each product, not as a separate product.
import StoreKit@MainActorfinal class StoreManager: ObservableObject { @Published private(set) var products: [Product] = [] @Published private(set) var isSubscribed = false @Published var errorMessage: String? private let productIDs = ["com.example.app.monthly", "com.example.app.yearly"] private var updatesTask: Task<Void, Never>? init() { updatesTask = Task { [weak self] in for await result in Transaction.updates { if case .verified(let transaction) = result { await transaction.finish() } await self?.refreshEntitlements() } } Task { await refreshEntitlements() } } deinit { updatesTask?.cancel() } func loadProducts() async { do { products = try await Product.products(for: productIDs) .sorted { $0.price < $1.price } } catch { errorMessage = "Could not load product information." } } func purchase(_ product: Product) async { do { switch try await product.purchase() { case .success(let verification): guard case .verified(let transaction) = verification else { errorMessage = "Purchase verification failed." return } await transaction.finish() await refreshEntitlements() case .userCancelled, .pending: break @unknown default: break } } catch { errorMessage = "Could not complete the purchase." } } func refreshEntitlements() async { var active = false for await result in Transaction.currentEntitlements { if case .verified(let transaction) = result, productIDs.contains(transaction.productID), transaction.revocationDate == nil { active = true } } isSubscribed = active } func restore() async { try? await AppStore.sync() await refreshEntitlements() }}
As you can see, the only door to paid state is refreshEntitlements(). Right after a purchase, at launch, and on Transaction.updates, all three go through it, so the paid state no longer disappears on restart.
Always include a "Restore purchases" button. Review looks for it, and it helps people who change devices.
Reflect the free trial in the UI
Whether a trial exists can be read from the product's introductory offer. Showing the period and the post-trial price together is safer for review and clearer for users.
func trialText(for product: Product) -> String? { guard let offer = product.subscription?.introductoryOffer, offer.paymentMode == .freeTrial else { return nil } let value = offer.period.value let span: String switch offer.period.unit { case .day: span = "\(value)-day" case .week: span = "\(value)-week" case .month: span = "\(value)-month" default: span = "\(value)-year" } return "\(span) free trial, then \(product.displayPrice)"}
Check eligibility with product.subscription?.isEligibleForIntroOffer, and do not show "free" to users who no longer qualify.
The paywall
struct PaywallView: View { @StateObject private var store = StoreManager() @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { List(store.products) { product in VStack(alignment: .leading, spacing: 4) { Text(product.displayName).font(.headline) if let trial = trialText(for: product) { Text(trial).font(.subheadline) } else { Text(product.displayPrice).font(.subheadline) } Button("Subscribe") { Task { await store.purchase(product) } } } .padding(.vertical, 4) } .navigationTitle("Premium") .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Close") { dismiss() } } ToolbarItem(placement: .bottomBar) { Button("Restore purchases") { Task { await store.restore() } } } } .task { await store.loadProducts() } .onChange(of: store.isSubscribed) { _, subscribed in if subscribed { dismiss() } } } }}
The two-parameter onChange requires iOS 17. If you support earlier versions, switch to the single-parameter form.
Deciding on price
I find prices hold up better when derived from your own numbers than borrowed from someone else's.
App Store Connect expands a base price to every storefront automatically, and you can override any of them. There is no country price to memorize. What to check is this:
Does the gap between monthly and yearly give people a reason to choose yearly?
Is the post-trial price the first thing visible on the paywall?
Do the amounts look natural in your main markets?
There is no fixed right discount for yearly. Compare it against monthly × 12 and adjust over a few months of measurement, so that a shift to yearly does not erode monthly revenue.
Flow: show the value first
Generated onboarding tends to stop at feature introductions. A paywall lands better right after the user has felt value once.
This is the order I use.
State what the app solves in one line on the first screen.
Let the user get one result inside the free scope.
Show the entrance to paid features right after that result.
Leave a place they can return to at any time, even after closing.
Never put the paywall first, and always provide a close button. A structure that does not look pushy is also safer in review.
How to think about ASO
Check keyword volumes against your own App Store Connect analytics or an ASO tool. Numbers printed in articles shift with time and region.
Three implementation points are worth holding on to.
The keyword field is limited to 100 characters, so leave out words already in the title and subtitle.
Use the first screenshot to state the result users get, in words.
Write the opening of the description assuming it gets cut off in search results.
Design that supports retention
The first step to fewer cancellations is knowing why people leave. Look at renewal rate and cancellation timing in App Store Connect analytics.
If you use notifications, ask permission first and send only when there is genuinely something new. A local notification looks like this.
import UserNotificationsfunc scheduleReminder(afterDays days: Int, title: String, body: String) async { let center = UNUserNotificationCenter.current() guard (try? await center.requestAuthorization(options: [.alert, .sound])) == true else { return } let content = UNMutableNotificationContent() content.title = title content.body = body let trigger = UNTimeIntervalNotificationTrigger( timeInterval: TimeInterval(days * 24 * 60 * 60), repeats: false) try? await center.add(UNNotificationRequest( identifier: "retention-\(days)d", content: content, trigger: trigger))}
For offering a discount to people who cancelled, StoreKit's win-back offers are available. Supported OS versions and setup conditions change, so confirm them in Apple's StoreKit documentation before implementing.
Estimate revenue from your own numbers
A goal such as "500 subscribers at a given price" is easy to grasp, but plugging in someone else's conversion rate will miss. I suggest filling in this table with your app's measured values.
Item
Where the value comes from
Monthly downloads
App Analytics in App Store Connect
Trial start rate
Trial starts divided by paywall views
Trial-to-paid conversion
Renewals after the trial ends
Monthly retention
Subscriber count over time
Net price per subscriber
List price minus fees and tax
New paying members each month come roughly from downloads × start rate × conversion, and retention wears that down. Take one or two months of real data after launch and find the lowest value. Improvements usually pay off most where the number is weakest.
Reaching your first 10
My impression is that first subscribers come more often from people who can see your face than from ads.
Share honestly in a community you already use why you built it and what you struggled with.
Ask the people who tried it for their reactions directly, and fold them into the paywall wording.
Write down improvements after release in a few lines, and tell people at every update.
Promotion and sharing read differently. "Here is where I got stuck and how I fixed it" puts you in the reader's seat more than "please buy" does.
Where to start
As your next step, run "buy, kill the app, relaunch" once on a real device. If the paid state survives, the foundation of your billing is in place.
From there, check the review items one by one and fill the numbers with your own measurements. It is plain work, but this accumulation is what turns generated code into a product.
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.