How to use StoreKit views to build a subscription app paywall with SwiftUI

A guide on Apple’s new StoreView, ProductView, and SubscriptionStoreView APIs for building native paywalls for your subscription app.

Charlie Chapman

Charlie Chapman

PublishedLast updated

The sample project used throughout this guide can be found at https://github.com/RevenueCat-Samples/storekit-views-demo-app.

At WWDC 2023 Apple introduced StoreKit views, a set of new APIs for rendering paywalls in your app using SwiftUI. These new APIs, available starting with iOS 17, enable developers to get a simple paywall up and running in their app quickly with some customization options to help them blend into your app.

StoreKit views rendered on different Apple devices. Source Apple
Figure 1: StoreKit views rendered on different Apple devices. Source Apple

In this guide, I’ll cover the three new StoreKit views, StoreView, ProductView, and SubscriptionStoreView, and show how to hook them up to your In App Purchases and integrate them into your app. I’ll also cover some of the tradeoffs of using these new APIs. To follow along and see all of this code in action, check out our StoreKit views Sample App.

NOTE: If you’re new to StoreKit, I highly recommend reading our StoreKit 2 tutorial. It will walk you through setting up App Store Connect, StoreKit configuration files, and how to handle purchase events for unlocking your in-app features.

For all of the examples in this guide I am assuming these products are set up in App Store Connect (for our sample project these products are set up in a local StoreKitConfiguration file).

Auto-Renewable Subscription

  • Group: Pro
    • Products:
      • “pro_weekly”
        • $0.49 / week
      • “pro_monthly”
        • $0.99 / month
      • “pro_yearly”
        • $12.99 / year 
        • Includes 1 week free trial

In-App Purchases

  • “pro_lifetime” – $89.99

How to use StoreView

Let’s start with StoreView. This is the most straightforward of the new StoreKit views. Here’s a simple code example.

1let productIds = ["pro_weekly", "pro_monthly", "pro_yearly", "pro_lifetime"]
2StoreView(ids: productIds)

With just this code, you can render a paywall showing each of your in-app purchases that looks like this.

StoreView with no modifications
Figure 2: StoreView with no modifications

This renders each of our auto-renewing subscription products as well as our one in-app purchase product in a simple list with a button for each that initiates a purchase.

If you have already made a purchase, StoreView will automatically show that product as purchased and disable the button. If you are subscribed to an auto-renewing plan and tap on a different plan within that subscription group, it will allow you to upgrade or downgrade and iOS’s built-in in-app purchase flow will explain how that billing works.

For a lifetime in-app purchase option outside of the subscription group however, the StoreView will not automatically cancel a user’s subscription if they purchase your lifetime option. So if you want to support that you’ll have to explain to your users to do that manually.

Out of the box this is pretty boring, so let’s spruce this up by adding an image for each product. You simply return a SwiftUI view in the StoreView’s callback and it will render it for each product. I’ve created a simple SwiftUI view called ProductImage that takes in a product id and renders a unique image for each that I’ll use here. 

1let productIds = ["pro_weekly", "pro_monthly", "pro_yearly", "pro_lifetime"]
2
3StoreView(ids: productIds) { product in
4    ProductImage(productId: product.id)
5}

Which renders this:

StoreView with product images
Figure 3: StoreView with product images

You can also add a button for users to restore their purchase using the storeButton modifier.

1let productIds = ["pro_weekly", "pro_monthly", "pro_yearly", "pro_lifetime"]
2
3StoreView(ids: productIds) { product in
4    ProductImage(productId: product.id)
5}
6.storeButton(.visible, for: .restorePurchases)

Which renders like this:

StoreView with Restore Purchases button
Figure 4: StoreView with Restore Purchases button

Each of these products look a little chunky, so one thing we can do is set the productViewStyle to compact. There are 3 different productViewStyles you can set, compact, regular, and large.

1let productIds = ["pro_weekly", "pro_monthly", "pro_yearly", "pro_lifetime"]
2
3StoreView(ids: productIds) { product in
4    ProductImage(productId: product.id)
5}
6.productViewStyle(.compact)
7.storeButton(.visible, for: .restorePurchases)
Comparison of productViewStyles on StoreView
Figure 5: Comparison of productViewStyles on StoreView

In the current Xcode beta as of writing (beta 6), there doesn’t appear to be a way to disable the close button on the top right corner. According to Apple’s documentation you should be able to hide this by adding a storeButton(.hidden, for: .close) modifier to your StoreView, but that API does not appear to be working in the current beta.

As far as other customizations, you can use the background and foregroundColor modifiers to tweak the look a little bit, but things are fairly limited. For a little bit more control, let’s check out ProductView.

How to use ProductView

ProductView is a SwiftUI view that renders a single StoreKit product. It’s basically the building block that StoreView is using to show your whole paywall. Using ProductView you can make a paywall that’s much more customized to fit your app.

Each ProductView takes a single productId and renders a product just like you saw in the StoreView. And just like the StoreView, you can customize the style into 3 different types: compact, regular, and large. You can also add an image by passing a SwiftUI view into the ProductView callback. 

1ProductView(id: "pro_monthly") {
2    ProductImage(productId: "pro_monthly")
3}
4.productViewStyle(.compact)

This renders a functional, though pretty boring, view for purchasing an individual product.

Single ProductView with no modifications
Figure 6: Single ProductView with no modifications

To spruce things up you can add a background with rounded corners, some padding, and tweak the foreground color like this.

1ProductView(id: “pro_monthly”) {
2    ProductImage(productId: “pro_monthly”)
3}
4.padding()
5.background(.thinMaterial, in: .rect(cornerRadius: 20))
6.productViewStyle(.compact)
7.foregroundColor(.black)
8.padding(.horizontal)



Now that you’re controlling each element separately you can mix and match different productViewStyle’s within a custom SwiftUI view to build a much more custom paywall.

Custom styled paywall built using different ProductViews
Figure 7: Custom styled paywall built using different ProductViews

There are a few drawbacks to this approach. ProductView will not handle rendering the Restore Purchases button for you like StoreView will, so you’ll have to render that on your own. You can refer to our StoreKit 2 guide or look at this guide’s sample project for an example of how to do that.

ProductView also behaves the same way as StoreView when it comes to mixing auto-renewing subscriptions with one time in-app purchases. Upgrades and downgrades between auto-renewing subscriptions will work automatically, but users will be able to purchase a lifetime in-app purchase and the system will not automatically cancel their subscription.

How to use SubscriptionStoreView

This brings us to the last new StoreKit view, SubscriptionStoreView. This SwiftUI view is specifically tailored for rendering a paywall for a single subscription group set up in App Store Connect. This means in our example, it will not render our lifetime in-app purchase product at all. If you want to build a paywall that mixes auto-renewing subscriptions and in-app purchases, this is not the view for you.

If you are sticking with just a single auto-renewing subscription group however, SubscriptionStoreView is more powerful than StoreView or ProductView.

Let’s start with the most basic implementation. You can initialize a SubscriptionStoreView with a subscription group id, or a list of product ids if you don’t want to include all of the products in a group for some reason.

1SubscriptionStoreView(groupID: "GROUP-ID")

This one line of code will render this view.

SubscriptionStoreView with no modifications
Figure 8: SubscriptionStoreView with no modifications

There’s actually a lot going on here. Starting from the top, it renders your app icon with the name of your subscription group, in this case the excitingly named “Pro”.

Next it shows each of the products in your subscription group as selectable elements. The yearly product picks up that there is a 1 week trial and renders that as well.

At the bottom we have a clear CTA button that updates based on the user’s selection. If the user is eligible for a trial for the selected product, it shows “Try It Free”. Otherwise it shows “Subscribe”. The text above the button also updates based on the selection with the appropriate description for how billing works for the selected plan. This is an area that’s frequently rejected by App Review for stylistic reasons so presumably the choices Apple is making here should make it less likely to get a rejection.

If the user has already subscribed to a product within the group, SubscriptionStoreView gives it a clear badge and still allows the user to upgrade or downgrade their plan.

Automatically changing CTA button text depending on which plan is selected
Figure 9: Automatically changing CTA button text depending on which plan is selected

If you want to change the copy of the CTA button you can choose between a few options using the subscriptionStoreButtonLabel modifier. The current options are action, displayName, price, multiline, and singleline. Apple is clear to point out that these options are preferences and are not guaranteed to behave the same way on all platforms.

Comparison of different subscriptionStoreButtonLabel settings on SubscriptionStoreView
Figure 10: Comparison of different subscriptionStoreButtonLabel settings on SubscriptionStoreView

Similar to StoreView, you can add a “Restore Purchases” button at the bottom with the storeButton modifier. You can also add a “Redeem Code” button this way on the SubsctiptionStoreView. Additionally, you can add links to your Privacy Policy and Terms of use using the subscriptionStorePolicyDestination modifier.

1SubscriptionStoreView(groupID: PurchaseManager.subscriptionGroupId)
2    .subscriptionStoreButtonLabel(.multiline)
3    .storeButton(.visible, for: .restorePurchases)
4    .storeButton(.visible, for: .redeemCode)
5    .subscriptionStorePolicyDestination(url: privacyUrl, for: .privacyPolicy)
6    .subscriptionStorePolicyDestination(url: termsUrl, for: .termsOfService)
SubscriptionStoreView with multiple storeButtons and policyDestinations enabled
Figure 11: SubscriptionStoreView with multiple storeButtons and policyDestinations enabled

One final functional element before getting into styling is visibleRelationships. When initializing your SubscriptionStoreView you can set the visibleRelationships parameter to crossgrade, upgrade, or all. This will change which products are presented to the user if they are already subscribed to another product in your subscription group. For more information on subscription groups you can read iOS Subscription Groups Explained.

Ok, so you have a subscription paywall that’s functionally complete, but… well it looks pretty bland. Let’s look at the ways we can customize its appearance.

Just like StoreView and ProductView, You can add an image for each product using the subscriptionStoreControlIcon. It renders a bit smaller though so you may want to lean more towards a smaller icon as the modifier name suggests.

1SubscriptionStoreView(groupID: PurchaseManager.subscriptionGroupId)
2    .subscriptionStoreControlIcon { product, subscriptionInfo in
3        ProductImage(productId: product.id)
4            .frame(height: 18)
5    }
SubscriptionStoreControlIcons rendered on a SubscriptionStoreView
Figure 12: SubscriptionStoreControlIcons rendered on a SubscriptionStoreView

The marketing content at the top looks pretty rough. Fortunately we can pass in a SwiftUI view to make this look however we want. We do this by passing a view into the SubscriptionStoreView’s callback.

1SubscriptionStoreView(groupID: PurchaseManager.subscriptionGroupId) {
2    VStack(spacing: 16) {
3        Image(systemName: "dollarsign.square.fill")
4            .resizable()
5            .scaledToFit()
6            .foregroundColor(.green)
7            .frame(height: 100)
8
9        Text("Subscribe to Pro!")
10            .font(.title)
11            .bold()
12
13        Text("Please buy me 🙏")
14            .font(.subheadline.weight(.medium))
15            .fontDesign(.rounded)
16            .multilineTextAlignment(.center)
17            .foregroundColor(.gray)
18    }
19    .padding()
20}
Custom marketing content at the top of SubscriptionStoreView
Figure 13: Custom marketing content at the top of SubscriptionStoreView

To add some color, let’s use the containerBackground(for:) modifier on the marketing content SwiftUI view to add a gradient background. If you set the placement to subscriptionStoreHeader the background will only render behind your marketing content. If you use subscriptionStoreFullHeight it will render your background behind the full screen.

Comparison of different placement settings for containerBackground on a SubscriptionViewStore
Figure 14: Comparison of different placement settings for containerBackground on a SubscriptionViewStore
1SubscriptionStoreView(groupID: PurchaseManager.subscriptionGroupId) {
2    VStack(spacing: 16) {
3        Image(systemName: "dollarsign.square.fill")
4            .resizable()
5            .scaledToFit()
6            .foregroundColor(.green)
7            .frame(height: 100)
8
9        Text("Subscribe to Pro!")
10            .font(.title)
11            .bold()
12
13        Text("Please buy me 🙏")
14            .font(.subheadline.weight(.medium))
15            .fontDesign(.rounded)
16            .multilineTextAlignment(.center)
17    }
18    .padding()
19    .foregroundColor(.white)
20    .containerBackground(for: .subscriptionStoreFullHeight) {
21        LinearGradient(colors: [.blue, .red], startPoint: .topLeading, endPoint: .bottomTrailing)
22    }
23
24}

By default, SubscriptionStoreView renders a material background behind all of the products. You can hide this using the backgroundStyle view modifier set to clear. You can then tweak the background behind each individual product using the subscriptionStorePickerItemBackground.

1SubscriptionStoreView(groupID: PurchaseManager.subscriptionGroupId) {
2    VStack(spacing: 16) {
3        Image(systemName: "dollarsign.square.fill")
4            .resizable()
5            .scaledToFit()
6            .foregroundColor(.green)
7            .frame(height: 100)
8
9        Text("Subscribe to Pro!")
10            .font(.title)
11            .bold()
12
13        Text("Please buy me 🙏")
14            .font(.subheadline.weight(.medium))
15            .fontDesign(.rounded)
16            .multilineTextAlignment(.center)
17    }
18    .padding()
19    .foregroundColor(.white)
20    .containerBackground(for: .subscriptionStoreFullHeight) {
21        LinearGradient(colors: [.blue, .red], startPoint: .topLeading, endPoint: .bottomTrailing)
22    }
23}
24.backgroundStyle(.clear)
25.subscriptionStorePickerItemBackground(.thinMaterial)
SubscriptionStoreView with clear background and material set for subscriptionStorePickerItemBackground
Figure 15: SubscriptionStoreView with clear background and material set for subscriptionStorePickerItemBackground

While not quite as flexible as building your own SwiftUI paywall using the ProductView building blocks, SubscriptionStoreView allows you to create a more traditional subscription app paywall with a product picker and a strong call to action button at the bottom. It’s automatic handling of trial eligibility and upgrades also make it a good option.

Benefits and tradeoffs

These new StoreKit views are a nice way to easily get a usable paywall for your app. Combined with StoreKit 2, you can set up and manage a subscription app using just these native tools. 

There are some missing pieces however. The styling options are very limited. You have more flexibility with ProductView, but those views are not really suited well for a subscription app and you have to manage trial eligibility, restore functionality, and upgrade/crossgrade options manually. 

SubscriptionStoreView is more optimized for subscription apps, but doesn’t allow for one time purchase options like a lifetime purchase. You also cannot build multi-page paywalls where one plan is highlighted to purchase, but alternative plans can be found on a separate page.

Using with RevenueCat

StoreKit views are great for quickly rendering a paywall UI, but you will still need to handle transaction updates within your app, manage which features your users are entitled to, and validate your transactions with your server to prevent fraud. Here at RevenueCat we build tools to make all of that easy and do so much more.

In fact, we’ve already updated our SDK to make using StoreKit views with RevenueCat incredibly easy. If you’re building for iOS 17, have the SDK installed, and import RevenueCat in your file, both StoreView and SubscriptionStoreView will have a new initializer allowing you to pass in a RevenueCat offering object.

1StoreView(for: offering)

Or

1SubscriptionStoreView(for: offering)

Everything else should work exactly the same as in this guide, but it will now be powered by RevenueCat offerings. This means you’ll get all of RevenueCat’s charts, ability to run experiments to optimize your paywall offerings, validate your transactions, and of course utilize RevenueCat’s world class SDK to make managing your entitlements and transaction events in your code a breeze.

RevenueCat Paywalls

If you want a more customizable solution that’s also super easy to use, RevenueCat has recently released a new paywalls feature that expands the capabilities of your paywalls. RevenueCat paywalls allow you to define a paywall from our dashboard, and our SDK automatically renders that paywall natively (not a web view!) on device for you. This allows you to change your paywalls from our backend without needing to wait for an update to get through App Review. Plus it hooks up directly with our powerful experiments features to make optimizing your paywalls a breeze.

RevenueCat paywalls offer a few features that may make it a better choice over StoreKit views such as:

  • iOS 15+ support
  • Ability to include non-consumable in-app purchases such as lifetime products inside of your subscription paywall
  • Our growing list of templates are much more flexible and follow industry best-practices for high performing paywalls
  • Flexible footer view rendering mode allows for highly customizable native marketing content
  • Easily run experiments to optimize your paywalls
  • Cross-platform support for Android (coming soon)

Final thoughts

Our biggest goal at RevenueCat is to help developers make more money. We do this by offering an entire mobile subscription platform with native and cross-platform SDKs to make integrating in-app purchases as easy as possible in any application. Our customers don’t need to worry about the different native subscription APIs or the intricacies of keeping subscription status in sync with renewals, cancellations, and billing issues.

I wrote this guide because I believe StoreKit views may be the perfect solution for some of you to get started with subscription apps. Even if you don’t ultimately decide to use our tools, we want to help all developers succeed where we can.

In-App Subscriptions Made Easy

See why thousands of the world's tops apps use RevenueCat to power in-app purchases, analyze subscription data, and grow revenue on iOS, Android, and the web.

Related posts

What is SKErrorDomain Error 0 and what can I do about it?
Engineering

What is SKErrorDomain Error 0 and what can I do about it?

What to do when seeing SKErrorDomain Error code 0 from StoreKit on iOS.

Charlie Chapman

Charlie Chapman

April 24, 2024

How we solved RevenueCat’s biggest challenges on data ingestion into Snowflake
How we solved RevenueCat’s biggest challenges on data ingestion into Snowflake
Engineering

How we solved RevenueCat’s biggest challenges on data ingestion into Snowflake

Challenges, solutions, and insights from optimizing our data ingestion pipeline.

Jesús Sánchez

Jesús Sánchez

April 15, 2024

How RevenueCat handles errors in Google Play’s Billing Library
How RevenueCat handles errors in Google Play’s Billing Library  
Engineering

How RevenueCat handles errors in Google Play’s Billing Library  

Lessons on Billing Library error handling from RevenueCat's engineering team

Cesar de la Vega

Cesar de la Vega

April 5, 2024

Want to see how RevenueCat can help?

RevenueCat enables us to have one single source of truth for subscriptions and revenue data.

Olivier Lemarié, PhotoroomOlivier Lemarié, Photoroom
Read Case Study