What You'll Build
In this codelab you will design a paywall with RevenueCat Paywalls V2, the no-code remote
builder, and display it natively in a SwiftUI app with RevenueCatUI. You will not hardcode the
paywall: you build it in the dashboard and publish it, so you (or your marketing team) can change it later
without an app update.
You will design the paywall using data-backed choices from RevenueCat's State of Subscription Apps 2026, which analyzed how real paywalls are built:
- Two plans on the paywall (the most common layout, used by 41 to 60 percent of apps).
- A highlighted plan (74.5 percent of paywalls highlight pricing) with a savings badge on the annual option.
- Free-trial messaging (on 54 percent of paywalls) and a "Cancel anytime" assurance.
- A "Continue" CTA (the most common call to action), and no countdown timers or progress bars (nearly absent in top paywalls).
pro
entitlement. New to that? Start with the iOS codelab or the
Configure the SDK guide.
What Paywalls V2 Is
Paywalls V2 is a no-code, remotely configurable, fully native paywall builder. You design it visually in the RevenueCat dashboard, and the SDK renders it natively (SwiftUI on iOS, Jetpack Compose on Android). Because it is remote, you can change the entire paywall without shipping an app update.
Why this matters:
- Iterate without releases. Edit copy, layout, pricing emphasis, and images from the dashboard.
- It is native. No web view; it renders with native components and adapts to light/dark mode.
- It feeds experiments. Because paywalls attach to offerings, you can A/B test them (Step 10).
purchases-ios 5.16.0 or newer (and recent SDK versions on other platforms). The older builder is
now referred to as legacy paywalls and uses different template-variable names.
Set Up Your Offering and Packages
A paywall renders the packages in an offering, so configure those first (in the RevenueCat dashboard, under your project).
- Make sure your store products exist (a monthly and an annual subscription) and are imported into RevenueCat.
- Attach both products to your
proentitlement. - In your default offering, add two packages: a
$rc_monthlyand a$rc_annualpackage, each wrapping the matching product.
New to offerings and packages? See Get products and prices.
Design the Paywall in the Editor
In the dashboard, go to Paywalls and click Create paywall. Start from a template (recommended), then attach it to your offering in the editor's paywall properties ("Select an Offering for your Paywall"). Now build the data-backed layout:
- Two Package components: one for the monthly package, one for the annual. Two-plan paywalls are the most common layout.
- Highlight the annual plan and add a savings badge using the discount variable.
- Add a Feature list of what
prounlocks, plus a short headline. - Add free-trial messaging and a "Cancel anytime" line.
- Set the Purchase button (CTA) label to "Continue".
Use template variables (double curly braces) so prices and offers always come from the store:
{{ product.price }} -> "$9.99" (the package price)
{{ product.price_per_period }} -> "$59.99/year" (price with the billing period)
{{ product.relative_discount }} -> "37%" (annual savings vs the priciest plan)
{{ product.offer_price }} -> "free" (the intro/trial price, when there is one)
When you are happy, click Publish. It is now live, no app update needed.
Install RevenueCatUI
Paywalls render through the RevenueCatUI library. In Xcode, add the Swift package
https://github.com/RevenueCat/purchases-ios-spm.git and select both the
RevenueCat and RevenueCatUI products.
import SwiftUI
import RevenueCat // the core SDK
import RevenueCatUI // the paywall views
purchases-ios 5.16.0 or newer. The SDK should already be configured at app launch (see the
prerequisite in Step 1).
Gate Access with presentPaywallIfNeeded
The simplest way to show the paywall is the presentPaywallIfNeeded view modifier. It presents the
offering's paywall only when the user does not have the entitlement, and dismisses automatically once
they do.
import SwiftUI
import RevenueCat
import RevenueCatUI
struct RootView: View {
var body: some View {
ContentView()
.presentPaywallIfNeeded(
requiredEntitlementIdentifier: "pro",
// For a hard paywall, add: presentationMode: .fullScreen
purchaseCompleted: { customerInfo in
print("Purchased: \(customerInfo.entitlements.active.keys)")
},
restoreCompleted: { customerInfo in
// Dismisses automatically if "pro" is now active.
print("Restored: \(customerInfo.entitlements.active.keys)")
}
)
}
}
For a hard paywall (no way past it until purchase), present it full screen with
presentationMode: .fullScreen. Hard paywalls convert far better than freemium in the data, so
this is a common choice for content that requires a subscription.
Present PaywallView in a Sheet
When you want to show the paywall on demand (for example from an "Upgrade" button), present
PaywallView in a sheet. It renders the current offering's paywall.
struct SettingsView: View {
@State private var showPaywall = false
var body: some View {
Button("Upgrade to Pro") { showPaywall = true }
.sheet(isPresented: $showPaywall) {
// No need to wrap this in a ScrollView, the paywall handles scrolling.
PaywallView()
}
}
}
PaywallView() uses the current offering. To present a specific offering (which opts out of A/B
experiment routing), pass it explicitly: PaywallView(offering: yourOffering).
displayCloseButton parameter has no
effect for V2 paywalls. If you want a close button, add it as a component in the editor instead.
Handle Completion and Dismissal
Attach handlers to react to purchases, restores, and the user requesting to close the paywall.
.sheet(isPresented: $showPaywall) {
PaywallView()
.onPurchaseCompleted { customerInfo in
// Verify access before unlocking; receiving CustomerInfo is not proof of entitlement.
if customerInfo.entitlements.active["pro"]?.isActive == true {
showPaywall = false
}
}
.onRestoreCompleted { customerInfo in
if customerInfo.entitlements.active["pro"]?.isActive == true {
showPaywall = false
}
}
.onRequestedDismissal {
// Fires on close-button tap (or purchase completion).
showPaywall = false
}
}
CustomerInfo
does not by itself mean the user is entitled. Check customerInfo.entitlements.active["pro"]?.isActive
before unlocking content. PaywallView uses onRequestedDismissal (there is no
onDismiss modifier on it).
Display on Other Platforms
The same dashboard paywall renders on every platform. The display APIs:
React Native
import RevenueCatUI, { PAYWALL_RESULT } from 'react-native-purchases-ui';
const result = await RevenueCatUI.presentPaywallIfNeeded({
requiredEntitlementIdentifier: 'pro',
});
// result is PURCHASED, RESTORED, CANCELLED, ERROR, or NOT_PRESENTED
Android (Jetpack Compose)
// paywallActivityLauncher is a PaywallActivityLauncher created in onCreate.
// Launch the paywall only if the entitlement is missing:
paywallActivityLauncher.launchIfNeeded(requiredEntitlementIdentifier = "pro")
Flutter
import 'package:purchases_ui_flutter/purchases_ui_flutter.dart';
final result = await RevenueCatUI.presentPaywallIfNeeded("pro");
// result is a PaywallResult: notPresented, error, cancelled, purchased, restored
Test, A/B, and Recap
Test
- Run the app as a user without
proand confirm the paywall appears with your two plans, the highlighted annual option, the trial line, and the "Continue" CTA. - Complete a sandbox purchase and confirm the paywall dismisses and content unlocks.
- Edit the paywall in the dashboard, publish, and relaunch: the change appears with no new build.
A/B test it next
The real gains come from testing. Because a paywall attaches to an offering, you can create a second offering with a different paywall and run a RevenueCat Experiment to see which converts better, then serve different paywalls to different audiences with Targeting and Placements.
What you built
A remote, no-code, native paywall with a data-backed two-plan layout, displayed in SwiftUI with
presentPaywallIfNeeded and PaywallView, and ready to render on every platform and
to feed experiments.
Keep going
- iOS In-App Purchases & Paywalls: the full SDK integration codelab.
- Get products and prices and Check intro eligibility: offering building blocks.
- RevenueCat: Paywalls and Displaying Paywalls.