A subscription-based iOS app usually needs more than a paywall. You also need to introduce the product, explain its value, load plans, handle purchase outcomes, restore access, and keep premium features in sync with StoreKit.
When those responsibilities are mixed into a single SwiftUI screen, even small changes become difficult. A better approach is to treat onboarding, purchasing, and entitlement state as separate concerns.
Here is a practical way to design that flow.
1. Keep onboarding independent from subscription state
Onboarding answers: Has the user seen the introduction? Subscription state answers: Does the user have access to premium features?
These are different questions. Completing onboarding should not automatically grant premium access, and dismissing a paywall should not force the user through onboarding again.
For a simple local onboarding flag, SwiftUI's @AppStorage works well:
import SwiftUI
struct AppEntryView: View {
@AppStorage("onboarding.completed") private var completed = false
var body: some View {
if completed {
MainView()
} else {
OnboardingView(onFinish: { completed = true })
}
}
}
Here MainView and OnboardingView are your app's own views; the snippet illustrates the routing boundary, not a complete sample application.
For more complex apps, keep the completion policy in a flow coordinator so the rest of the UI does not need to know where the flag is stored.
2. Model the paywall as UI, not as the source of truth
A paywall should present products and user actions. It should not decide whether a purchase is valid.
Think of the UI in terms of explicit states:
- Loading — subscription products are being requested.
- Ready — products and localized prices are available.
- Purchasing — a request is in progress.
- Pending — StoreKit reports a pending purchase.
- Error — an operation failed, with a clear retry path.
- Premium — verified entitlements grant access.
Keeping these states explicit makes it easier to avoid empty product cards, duplicate purchase taps, and contradictory messages.
3. Load real localized pricing from StoreKit 2
Don't hard-code a monthly or yearly price into a SwiftUI paywall. The price and currency can vary by storefront.
import StoreKit
func loadSubscriptionProducts(ids: [String]) async throws -> [Product] {
try await Product.products(for: ids)
}
Use each product's localized displayPrice in your UI, and show a proper loading or retry state if the request fails. Your App Store Connect product IDs must match the IDs used in your application.
4. Separate purchase results from premium access
A completed purchase flow isn't the same as a durable, current entitlement. Subscriptions can expire or be revoked, and transactions can be updated outside the paywall.
For auto-renewable subscription IDs, the following simplified check reads StoreKit's current verified entitlements:
import StoreKit
func hasPremiumAccess(productIDs: Set<String>) async -> Bool {
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result else { continue }
if productIDs.contains(transaction.productID) {
return true
}
}
return false
}
Transaction.currentEntitlements is intended to represent access now. StoreKit excludes refunded and revoked purchases from the sequence. Its subscription behavior also accounts for eligible grace-period states. For apps with multiple tiers, determine the actual access level from the matching verified products rather than returning a single Boolean.
A production implementation should also observe Transaction.updates, refresh entitlement state when transactions change, and finish verified transactions after delivering access. You may also need server-side verification or subscription-status APIs depending on your business model.
Apple's reference: Transaction.currentEntitlements.
5. Make restoration and failure states visible
Users may reinstall an app or switch devices. Provide a visible Restore Purchases action and make the resulting state understandable, including when no active entitlement is found.
Remember to handle canceled and pending purchases as distinct outcomes: they shouldn't be presented as successful premium unlocks. Disable or debounce purchase buttons while the same request is running, and give users a path to retry product loading after a network failure.
6. Treat accessibility and theme configuration as part of the architecture
Onboarding and paywalls are frequently shown on small screens and translated into multiple languages. Plan for:
- Dynamic Type, scrollable layouts, and buttons that remain reachable at larger text sizes.
- VoiceOver labels and values for selected subscription options.
- Right-to-left layout directions, without assuming that copy is automatically translated.
- Light and Dark appearance, semantic colors, and reduced-motion preferences.
- A clear separation between brand colors and purchase logic.
A reusable component is genuinely reusable only when it can adapt to different products without forcing developers to rewrite its internals.
7. Keep the integration surface small
An SDK becomes harder to adopt when every application needs to understand dozens of internal views and purchase helpers.
A practical public integration surface might expose a configuration object for onboarding content, StoreKit product IDs, premium benefits, and a visual theme; one flow host to embed in the app; and a small controller for presenting the paywall.
Internal views can evolve without requiring consuming apps to be rewritten.
Putting it together
For a demo of this approach, I built LaunchFlowUI, a commercial Swift Package that combines configurable SwiftUI onboarding, StoreKit 2 subscription paywalls, purchase restoration, entitlement tracking, and visual themes. It targets iOS 17+ and has no third-party dependencies.
The demo shows LaunchFlowUI integrated into a sample productivity app called Lumen Focus:
The packaged SDK has been tested with Xcode's local StoreKit purchase and entitlement scenarios. Live Apple Sandbox and production transactions have not yet been independently verified.
What patterns do you use to keep onboarding, purchase state, and entitlement tracking independent in your SwiftUI apps?
Top comments (0)