How to Build a Subscription Paywall in SwiftUI
Use StoreKit 2's Product.products(for:) to fetch your subscription offerings, then call product.purchase() and verify the result via Transaction.currentEntitlements. On iOS 17+ you can also drop in SubscriptionStoreView for a zero-effort native paywall.
import StoreKit
struct QuickPaywallView: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
SubscriptionStoreView(groupID: "YOUR_SUBSCRIPTION_GROUP_ID") {
VStack(spacing: 8) {
Image(systemName: "crown.fill")
.font(.system(size: 52))
.foregroundStyle(.yellow)
Text("Go Premium")
.font(.title.bold())
Text("Unlock everything, forever ad-free.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.top, 24)
}
.subscriptionStoreControlStyle(.prominentPicker)
.storeButton(.visible, for: .restorePurchases)
}
}
Full implementation
The pattern below pairs an @Observable view-model — which fetches products, drives the purchase flow, and tracks entitlements — with a fully custom paywall view. This gives you complete design control while still relying on StoreKit 2's automatic receipt verification and the modern Transaction async sequence for entitlement checks. Add a StoreKit Configuration file (File → New → StoreKit Configuration) to your Xcode project so the simulator can test purchases without hitting the App Store.
import StoreKit
import SwiftUI
// MARK: - Store error
enum StoreError: Error {
case failedVerification
}
// MARK: - ViewModel
@Observable
final class PaywallViewModel {
var products: [Product] = []
var selectedProduct: Product?
var isPurchasing = false
var isPro = false
var errorMessage: String?
// Replace with your actual product IDs from App Store Connect
private let productIDs = ["com.yourapp.premium.monthly",
"com.yourapp.premium.annual"]
init() {
Task {
await loadProducts()
await refreshEntitlements()
}
}
func loadProducts() async {
do {
let fetched = try await Product.products(for: productIDs)
products = fetched.sorted { $0.price < $1.price }
selectedProduct = products.first
} catch {
errorMessage = "Could not load products: \(error.localizedDescription)"
}
}
func purchase(_ product: Product) async {
isPurchasing = true
defer { isPurchasing = false }
do {
let result = try await product.purchase()
switch result {
case .success(let verification):
let transaction = try checkVerified(verification)
await transaction.finish()
isPro = true
case .userCancelled, .pending:
break
@unknown default:
break
}
} catch {
errorMessage = error.localizedDescription
}
}
func restore() async {
do {
try await AppStore.sync()
await refreshEntitlements()
} catch {
errorMessage = error.localizedDescription
}
}
// Check every current entitlement on launch / after restore
private func refreshEntitlements() async {
for await result in Transaction.currentEntitlements {
if case .verified(let tx) = result,
tx.productType == .autoRenewable,
tx.revocationDate == nil {
isPro = true
return
}
}
}
private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .unverified: throw StoreError.failedVerification
case .verified(let value): return value
}
}
}
// MARK: - Paywall view
struct PaywallView: View {
@Environment(\.dismiss) private var dismiss
@State private var vm = PaywallViewModel()
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 28) {
// Hero
VStack(spacing: 10) {
Image(systemName: "crown.fill")
.font(.system(size: 64))
.foregroundStyle(.yellow)
.accessibilityHidden(true)
Text("Go Premium")
.font(.largeTitle.bold())
Text("One subscription — every feature, zero ads.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding(.top, 40)
// Feature bullets
VStack(alignment: .leading, spacing: 14) {
FeatureRow(icon: "infinity", text: "Unlimited projects")
FeatureRow(icon: "icloud.fill", text: "iCloud sync across devices")
FeatureRow(icon: "wand.and.stars", text: "AI-powered suggestions")
FeatureRow(icon: "bell.badge", text: "Priority support")
}
.padding(.horizontal, 24)
// Product picker
if vm.products.isEmpty {
ProgressView("Loading plans…")
.accessibilityLabel("Loading subscription plans")
} else {
VStack(spacing: 10) {
ForEach(vm.products, id: \.id) { product in
ProductRow(
product: product,
isSelected: vm.selectedProduct?.id == product.id
) { vm.selectedProduct = product }
}
}
.padding(.horizontal, 20)
}
// CTA
Button {
guard let p = vm.selectedProduct else { return }
Task { await vm.purchase(p) }
} label: {
Group {
if vm.isPurchasing {
ProgressView().tint(.white)
} else {
Text("Subscribe Now")
.fontWeight(.semibold)
}
}
.frame(maxWidth: .infinity)
.padding()
.background(vm.selectedProduct == nil ? Color.gray : Color.blue)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.disabled(vm.isPurchasing || vm.selectedProduct == nil)
.padding(.horizontal, 20)
.accessibilityLabel("Subscribe to \(vm.selectedProduct?.displayName ?? "selected plan")")
Button("Restore Purchases") {
Task { await vm.restore() }
}
.font(.footnote)
.foregroundStyle(.secondary)
Text("Subscriptions auto-renew unless cancelled 24 h before the renewal date.")
.font(.caption2)
.foregroundStyle(.tertiary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
}
.padding(.bottom, 40)
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Close") { dismiss() }
.accessibilityLabel("Close paywall")
}
}
.alert("Something went wrong", isPresented: Binding(
get: { vm.errorMessage != nil },
set: { if !$0 { vm.errorMessage = nil } }
)) {
Button("OK") { vm.errorMessage = nil }
} message: {
Text(vm.errorMessage ?? "")
}
}
}
}
// MARK: - Feature row
struct FeatureRow: View {
let icon: String
let text: String
var body: some View {
HStack(spacing: 14) {
Image(systemName: icon)
.foregroundStyle(.blue)
.frame(width: 22)
.accessibilityHidden(true)
Text(text).font(.body)
}
.accessibilityElement(children: .combine)
.accessibilityLabel(text)
}
}
// MARK: - Product row
struct ProductRow: View {
let product: Product
let isSelected: Bool
let onSelect: () -> Void
var body: some View {
Button(action: onSelect) {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(product.displayName).font(.headline)
if let intro = product.subscription?.introductoryOffer {
Text("Starts with \(intro.displayPrice) intro offer")
.font(.caption)
.foregroundStyle(.green)
}
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(product.displayPrice)
.font(.title3.bold())
if let sub = product.subscription {
Text("/ \(sub.subscriptionPeriod.unit.localizedDescription)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.foregroundStyle(isSelected ? .blue : .secondary)
.font(.title3)
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 14)
.stroke(isSelected ? Color.blue : Color.secondary.opacity(0.25),
lineWidth: isSelected ? 2 : 1)
)
}
.buttonStyle(.plain)
.accessibilityAddTraits(isSelected ? [.isSelected] : [])
.accessibilityLabel("\(product.displayName), \(product.displayPrice)")
.accessibilityHint(isSelected ? "Selected" : "Tap to select this plan")
}
}
// MARK: - Preview
#Preview {
PaywallView()
}
How it works
-
Product fetching (
loadProducts()).Product.products(for:)is an async call that hits the App Store (or your local StoreKit config in the simulator) and returns fully populatedProductobjects — including display price in the user's locale, subscription period, and any introductory offers — no SKProductsRequest delegate needed. -
Purchase flow (
product.purchase()). The call presents Apple's standard payment sheet and returns aProduct.PurchaseResult. The.successcase wraps aVerificationResult<Transaction>; callingcheckVerified(_:)throws on tampered receipts, giving you server-quality security on-device. -
Finishing transactions (
transaction.finish()). Always callfinish()after delivering the entitlement. StoreKit 2 withholds future transaction updates until you confirm delivery, preventing infinite re-delivery loops. -
Entitlement refresh (
Transaction.currentEntitlements). This async sequence yields the latest verified transaction for every product the user is currently entitled to. The view-model iterates it on launch and after restore, settingisPro = truewithout requiring a server round-trip. -
Restore purchases (
AppStore.sync()). CallingAppStore.sync()refreshes the local transaction store from Apple's servers, after whichTransaction.currentEntitlementsreflects any active subscriptions the user holds (even from another device).
Variants
Zero-code native paywall with SubscriptionStoreView
iOS 17 ships a ready-made, App Store Review–compliant paywall. Pass your subscription group ID (find it in App Store Connect → Subscriptions) and a custom marketing header. Apple renders the plan picker and purchase button automatically.
import StoreKit
struct NativePaywallView: View {
var body: some View {
SubscriptionStoreView(groupID: "YOUR_GROUP_ID") {
// Custom marketing header
VStack(spacing: 10) {
Image(systemName: "crown.fill")
.font(.system(size: 56))
.foregroundStyle(.yellow)
Text("Go Premium")
.font(.largeTitle.bold())
Text("Everything unlocked, one price.")
.foregroundStyle(.secondary)
}
.padding(.top, 32)
}
// .compactPicker hides the prominent tier comparison
.subscriptionStoreControlStyle(.prominentPicker)
.subscriptionStorePolicyDestination(for: .privacyPolicy) {
Text("Privacy Policy content here")
}
.subscriptionStorePolicyDestination(for: .termsOfService) {
Text("Terms of Service content here")
}
.storeButton(.visible, for: .restorePurchases)
.storeButton(.visible, for: .policies)
.tint(.blue)
}
}
#Preview {
NativePaywallView()
}
Checking for an active subscription anywhere in the app
Rather than re-fetching entitlements on every screen, lift PaywallViewModel (or a dedicated StoreService singleton) into your app's @main struct and inject it via the SwiftUI environment. Gate premium features with a simple if storeService.isPro { … } check; because the view-model is @Observable, every view that reads isPro re-renders automatically when the subscription state changes.
Common pitfalls
-
🚫 iOS version mismatch:
SubscriptionStoreViewrequires iOS 17+.ProductandTransactionare available from iOS 15+. If you still support iOS 16, use@available(iOS 17, *)guards and fall back to the manualProductapproach for older OS versions. -
🧪 Simulator testing without a StoreKit config:
Product.products(for:)returns an empty array on the simulator unless you've added a StoreKit Configuration file (File → New → File → StoreKit Configuration) and selected it in Edit Scheme → Run → Options → StoreKit Configuration. Missing this step makes the product picker silently empty with no error. -
⚠️ Forgetting
transaction.finish(): Unfinished transactions re-appear inTransaction.unfinishedon every app launch. Always callfinish()after you have delivered the entitlement to the user, not before. -
♿ Accessibility on product rows: Use
.accessibilityAddTraits([.isSelected])on the selected plan card so VoiceOver announces the current selection. Without this, users navigating by voice cannot tell which plan is chosen before tapping Subscribe. -
📜 App Store Review — required disclosures: Apple requires visible links to your Privacy Policy and Terms of Service on any paywall. Use
.subscriptionStorePolicyDestinationwithSubscriptionStoreView, or add manual links if you build a custom UI. Omitting them is a common rejection reason (guideline 3.1.2).
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement a subscription paywall in SwiftUI for iOS 17+. Use StoreKit Product, Transaction.currentEntitlements, and AppStore.sync() for restore. Make it accessible (VoiceOver labels on product rows, accessibilityAddTraits for selected state). Include links to Privacy Policy and Terms of Service. Add a #Preview with a realistic StoreKit config stub.
In Soarias's Build phase, paste this prompt directly into the Claude Code panel after scaffolding your app's data model — the agent will wire up the StoreKit configuration file, the observable store service, and the paywall sheet in a single pass.
Related
FAQ
Does this work on iOS 16?
The Product API (StoreKit 2) is available from iOS 15, so the manual paywall view works on iOS 15 and 16. However, SubscriptionStoreView and subscriptionStoreControlStyle are iOS 17+ only. Wrap them in if #available(iOS 17, *) { … } else { /* custom view */ } to stay backwards compatible.
How do I test subscription renewals in the simulator?
In Xcode's StoreKit Configuration file, you can set the subscription renewal rate (e.g. monthly → every 30 seconds) and simulate billing failures, cancellations, and refunds via Debug → StoreKit → Manage Transactions. For device testing, use a Sandbox tester account in App Store Connect — sandbox monthly subscriptions renew every 5 minutes, and you can force expiry from the Sandbox subscription management page on device.
What is the UIKit equivalent?
UIKit has no equivalent to SubscriptionStoreView. The traditional path is SKProductsRequest / SKPaymentQueue (StoreKit 1), which requires delegate callbacks and manual receipt parsing. StoreKit 2's Product API can be used from UIKit via async/await, but the view-layer helpers (SubscriptionStoreView, StoreView) are SwiftUI-only. For greenfield apps, SwiftUI + StoreKit 2 is strongly preferred.
Last reviewed: 2026-05-12 by the Soarias team.