How to Build a Subscription Paywall in SwiftUI

iOS 17+ Xcode 16+ Advanced APIs: StoreKit/Product Updated: May 12, 2026
TL;DR

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

  1. 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 populated Product objects — including display price in the user's locale, subscription period, and any introductory offers — no SKProductsRequest delegate needed.
  2. Purchase flow (product.purchase()). The call presents Apple's standard payment sheet and returns a Product.PurchaseResult. The .success case wraps a VerificationResult<Transaction>; calling checkVerified(_:) throws on tampered receipts, giving you server-quality security on-device.
  3. Finishing transactions (transaction.finish()). Always call finish() after delivering the entitlement. StoreKit 2 withholds future transaction updates until you confirm delivery, preventing infinite re-delivery loops.
  4. 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, setting isPro = true without requiring a server round-trip.
  5. Restore purchases (AppStore.sync()). Calling AppStore.sync() refreshes the local transaction store from Apple's servers, after which Transaction.currentEntitlements reflects 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

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.