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).
Prerequisite: a RevenueCat project with the SDK already configured and a 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).
Version note. Paywalls V2 went GA in June 2025. Paywalls built in the V2 editor require 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).

  1. Make sure your store products exist (a monthly and an annual subscription) and are imported into RevenueCat.
  2. Attach both products to your pro entitlement.
  3. In your default offering, add two packages: a $rc_monthly and a $rc_annual package, each wrapping the matching product.
Prices come from the store. Packages wrap the underlying store products, which carry the localized price. Your paywall will display those prices through variables (next step), so you never hardcode a price or a product id in the app.

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:

  1. Two Package components: one for the monthly package, one for the annual. Two-plan paywalls are the most common layout.
  2. Highlight the annual plan and add a savings badge using the discount variable.
  3. Add a Feature list of what pro unlocks, plus a short headline.
  4. Add free-trial messaging and a "Cancel anytime" line.
  5. Set the Purchase button (CTA) label to "Continue".

Use template variables (double curly braces) so prices and offers always come from the store:

text
{{ 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.

Skip the gimmicks. The data shows countdown timers and progress bars are nearly absent from top-performing paywalls. The Countdown component exists, but you do not need it. Clear plans, a highlighted annual option, and a trial line do the work.

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.

swift
import SwiftUI
import RevenueCat     // the core SDK
import RevenueCatUI   // the paywall views
Minimum versions. RevenueCatUI requires iOS 15.0 or newer, and Paywalls V2 requires 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.

swift
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.

If no paywall is configured for the offering, the SDK shows a default paywall listing the offering's packages, rather than erroring. So this code works even before you finish designing, then upgrades automatically once you publish your V2 paywall.

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.

swift
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).

Do not add a close button in code. The 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.

swift
.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
        }
}
Always confirm the entitlement. A completion handler giving you a 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

tsx
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)

kotlin
// paywallActivityLauncher is a PaywallActivityLauncher created in onCreate.
// Launch the paywall only if the entitlement is missing:
paywallActivityLauncher.launchIfNeeded(requiredEntitlementIdentifier = "pro")

Flutter

dart
import 'package:purchases_ui_flutter/purchases_ui_flutter.dart';

final result = await RevenueCatUI.presentPaywallIfNeeded("pro");
// result is a PaywallResult: notPresented, error, cancelled, purchased, restored
One design, every platform. Because the paywall is defined in the dashboard, you design once and it renders natively on iOS, Android, React Native, and Flutter. Paywall UI requires Android API 24 or newer.

Test, A/B, and Recap

Test

  1. Run the app as a user without pro and confirm the paywall appears with your two plans, the highlighted annual option, the trial line, and the "Continue" CTA.
  2. Complete a sandbox purchase and confirm the paywall dismisses and content unlocks.
  3. 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