The Flutter Kit logoThe Flutter Kit
Guide

Flutter Paywall Design: Best Practices for High-Converting Subscription Screens

Your paywall is probably the most important screen in your entire Flutter app. A 1% improvement in conversion can mean thousands of dollars in annual revenue across both iOS and Android. Here is how to design paywalls that people actually tap 'Subscribe' on.

Ahmed GaganAhmed Gagan
15 min read

Why Paywall Design Is the Highest-ROI Investment

Let me start with a number that should change how you think about paywall design. According to RevenueCat's 2025 benchmark data, the difference between a bottom-quartile paywall (1.5% conversion) and a top-quartile paywall (6% conversion) is a 4x difference in revenue — from the exact same app, with the exact same number of users, on both iOS and Android.

You could spend three months adding new features, or you could spend a weekend redesigning your paywall and potentially quadruple your revenue. No other screen in your app has this kind of leverage. Your paywall is not just a screen — it is your business model rendered in pixels.

With Flutter, you have the unique advantage of designing one paywall that works on both platforms. But you also need to handle platform-specific nuances — iOS users spend more on subscriptions while Android users may prefer lifetime purchases or lower price points. This guide covers both.

Anatomy of a High-Converting Paywall

Every effective paywall contains the same core elements. Miss one, and your conversion suffers:

1. A Headline That Addresses Pain, Not Features

Bad: "Unlock Premium Features." Good: "Never lose a workout streak again." Great: "Join 12,000+ people who track every workout." Your headline should remind users why they downloaded your app. It should address the problem they are solving or the outcome they want.

2. Social Proof

Include your App Store/Google Play rating and review count near the top. "Rated 4.8 by 2,400+ users" creates immediate trust. Social proof reduces the perceived risk of subscribing.

3. Feature Comparison (Free vs Premium)

Show users exactly what they are missing. A side-by-side Free vs Pro comparison is one of the most effective paywall elements. Keep it to 4-6 features.

4. Pricing with Smart Defaults

Present pricing options with the annual plan pre-selected. Show the monthly equivalent ("Just $3.33/month"). Use "Best Value" or "Save 40%" badges on the annual plan.

5. Trial Messaging

If you offer a free trial, make it impossible to miss. "Start your 7-day free trial" should be part of the CTA. Below the button: "No charge until [date]. Cancel anytime."

6. Restore Purchases

Required by both Apple and Google. Place a "Restore Purchases" link below the subscribe button. It must be visible and functional on both platforms.

7. Close/Skip Option

Always provide a dismiss option. Apple will reject apps that trap users on the paywall. A small X button or "Not now" link is essential.

Pattern 1: The Classic Stack

Plans stacked vertically with the recommended plan highlighted. The most common and reliable pattern — simple, scannable, familiar across both platforms.

Best for: Most apps with 2-3 plans (monthly, annual, lifetime).

Avg conversion: 3.5-5% (top quartile: 6-8%)

class ClassicStackPaywall extends StatefulWidget {
  final List<Package> packages;
  final VoidCallback onClose;
  final Function(Package) onPurchase;
  final VoidCallback onRestore;

  const ClassicStackPaywall({
    super.key,
    required this.packages,
    required this.onClose,
    required this.onPurchase,
    required this.onRestore,
  });

  @override
  State<ClassicStackPaywall> createState() =>
      _ClassicStackPaywallState();
}

class _ClassicStackPaywallState
    extends State<ClassicStackPaywall> {
  Package? _selectedPackage;

  @override
  void initState() {
    super.initState();
    // Pre-select annual plan
    _selectedPackage = widget.packages.firstWhere(
      (p) => p.packageType == PackageType.annual,
      orElse: () => widget.packages.first,
    );
  }

  @override
  Widget build(BuildContext context) {
    final colors = Theme.of(context).colorScheme;

    return Scaffold(
      body: SafeArea(
        child: Column(
          children: [
            // Close button
            Align(
              alignment: Alignment.topRight,
              child: IconButton(
                onPressed: widget.onClose,
                icon: Icon(
                  Icons.close,
                  color: colors.onSurfaceVariant,
                ),
              ),
            ),

            Expanded(
              child: SingleChildScrollView(
                padding: const EdgeInsets.symmetric(
                  horizontal: 24,
                ),
                child: Column(
                  children: [
                    // Header
                    Icon(
                      Icons.star_rounded,
                      size: 56,
                      color: colors.primary,
                    ),
                    const SizedBox(height: 16),
                    Text(
                      'Unlock Everything',
                      style: Theme.of(context)
                          .textTheme
                          .headlineMedium
                          ?.copyWith(
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                    const SizedBox(height: 8),
                    Text(
                      'Join 10,000+ users who upgraded',
                      style: Theme.of(context)
                          .textTheme
                          .bodyMedium
                          ?.copyWith(
                        color: colors.onSurfaceVariant,
                      ),
                    ),
                    const SizedBox(height: 32),

                    // Plan cards
                    ...widget.packages.map((pkg) {
                      final isSelected =
                          _selectedPackage == pkg;
                      return _PlanCard(
                        package: pkg,
                        isSelected: isSelected,
                        onTap: () => setState(
                          () => _selectedPackage = pkg,
                        ),
                      );
                    }),

                    const SizedBox(height: 24),
                  ],
                ),
              ),
            ),

            // Bottom CTA section
            Padding(
              padding: const EdgeInsets.all(24),
              child: Column(
                children: [
                  FilledButton(
                    onPressed: _selectedPackage != null
                        ? () => widget.onPurchase(
                            _selectedPackage!)
                        : null,
                    style: FilledButton.styleFrom(
                      minimumSize:
                          const Size(double.infinity, 56),
                    ),
                    child: const Text(
                      'Start Free Trial',
                    ),
                  ),
                  const SizedBox(height: 8),
                  Text(
                    '7-day free trial, then '
                    '${_selectedPackage?.storeProduct.priceString ?? ""}'
                    '/year. Cancel anytime.',
                    style: Theme.of(context)
                        .textTheme
                        .bodySmall
                        ?.copyWith(
                      color: colors.onSurfaceVariant,
                    ),
                    textAlign: TextAlign.center,
                  ),
                  const SizedBox(height: 8),
                  TextButton(
                    onPressed: widget.onRestore,
                    child: Text(
                      'Restore Purchases',
                      style: TextStyle(
                        color: colors.onSurfaceVariant,
                        fontSize: 12,
                      ),
                    ),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class _PlanCard extends StatelessWidget {
  final Package package;
  final bool isSelected;
  final VoidCallback onTap;

  const _PlanCard({
    required this.package,
    required this.isSelected,
    required this.onTap,
  });

  @override
  Widget build(BuildContext context) {
    final colors = Theme.of(context).colorScheme;
    final isAnnual =
        package.packageType == PackageType.annual;

    return Padding(
      padding: const EdgeInsets.only(bottom: 12),
      child: InkWell(
        onTap: onTap,
        borderRadius: BorderRadius.circular(12),
        child: Container(
          padding: const EdgeInsets.all(16),
          decoration: BoxDecoration(
            border: Border.all(
              color: isSelected
                  ? colors.primary
                  : colors.outlineVariant,
              width: isSelected ? 2 : 1,
            ),
            borderRadius: BorderRadius.circular(12),
            color: isSelected
                ? colors.primaryContainer
                    .withOpacity(0.15)
                : null,
          ),
          child: Row(
            children: [
              Expanded(
                child: Column(
                  crossAxisAlignment:
                      CrossAxisAlignment.start,
                  children: [
                    Row(
                      children: [
                        Text(
                          package.storeProduct
                              .title,
                          style: Theme.of(context)
                              .textTheme
                              .titleMedium
                              ?.copyWith(
                            fontWeight:
                                FontWeight.w600,
                          ),
                        ),
                        if (isAnnual) ...[
                          const SizedBox(width: 8),
                          Container(
                            padding: const EdgeInsets
                                .symmetric(
                              horizontal: 8,
                              vertical: 2,
                            ),
                            decoration: BoxDecoration(
                              color: colors.primary,
                              borderRadius:
                                  BorderRadius.circular(
                                      4),
                            ),
                            child: Text(
                              'SAVE 40%',
                              style: TextStyle(
                                color:
                                    colors.onPrimary,
                                fontSize: 10,
                                fontWeight:
                                    FontWeight.bold,
                              ),
                            ),
                          ),
                        ],
                      ],
                    ),
                    const SizedBox(height: 4),
                    Text(
                      package.storeProduct.priceString,
                      style: Theme.of(context)
                          .textTheme
                          .bodyMedium
                          ?.copyWith(
                        color:
                            colors.onSurfaceVariant,
                      ),
                    ),
                  ],
                ),
              ),
              if (isSelected)
                Icon(
                  Icons.check_circle,
                  color: colors.primary,
                ),
            ],
          ),
        ),
      ),
    );
  }
}

Pattern 2: The Comparison Table

A feature grid showing exactly what Free users are missing. Particularly effective when your premium tier adds multiple clearly differentiated features.

Best for: Productivity, creative tools, and utility apps with 5+ premium features.

Avg conversion: 4-6% (top quartile: 7-10% when shown at a feature gate)

class ComparisonPaywall extends StatelessWidget {
  final VoidCallback onClose;
  final VoidCallback onSubscribe;
  final VoidCallback onRestore;

  const ComparisonPaywall({
    super.key,
    required this.onClose,
    required this.onSubscribe,
    required this.onRestore,
  });

  static const _features = [
    ('Basic notes', true, true),
    ('Cloud sync', false, true),
    ('AI assistant', false, true),
    ('Export to PDF', false, true),
    ('Custom themes', false, true),
    ('Unlimited folders', false, true),
  ];

  @override
  Widget build(BuildContext context) {
    final colors = Theme.of(context).colorScheme;

    return Scaffold(
      body: SafeArea(
        child: Column(
          children: [
            Align(
              alignment: Alignment.topRight,
              child: IconButton(
                onPressed: onClose,
                icon: const Icon(Icons.close),
              ),
            ),
            Expanded(
              child: SingleChildScrollView(
                padding: const EdgeInsets.symmetric(
                  horizontal: 24,
                ),
                child: Column(
                  children: [
                    Text(
                      'Free vs Pro',
                      style: Theme.of(context)
                          .textTheme
                          .headlineMedium
                          ?.copyWith(
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                    const SizedBox(height: 24),
                    // Comparison grid
                    Container(
                      padding:
                          const EdgeInsets.all(16),
                      decoration: BoxDecoration(
                        color: colors
                            .surfaceContainerLow,
                        borderRadius:
                            BorderRadius.circular(16),
                      ),
                      child: Column(
                        children: [
                          // Header
                          Row(
                            children: [
                              const Expanded(
                                child: Text(
                                  'Feature',
                                  style: TextStyle(
                                    fontWeight:
                                        FontWeight.bold,
                                    fontSize: 12,
                                  ),
                                ),
                              ),
                              SizedBox(
                                width: 60,
                                child: Text(
                                  'Free',
                                  textAlign:
                                      TextAlign.center,
                                  style: TextStyle(
                                    fontWeight:
                                        FontWeight.bold,
                                    fontSize: 12,
                                    color: colors
                                        .onSurfaceVariant,
                                  ),
                                ),
                              ),
                              SizedBox(
                                width: 60,
                                child: Text(
                                  'Pro',
                                  textAlign:
                                      TextAlign.center,
                                  style: TextStyle(
                                    fontWeight:
                                        FontWeight.bold,
                                    fontSize: 12,
                                    color:
                                        colors.primary,
                                  ),
                                ),
                              ),
                            ],
                          ),
                          const Divider(),
                          ..._features.map((f) {
                            final (name, free, pro) = f;
                            return Padding(
                              padding:
                                  const EdgeInsets
                                      .symmetric(
                                vertical: 10,
                              ),
                              child: Row(
                                children: [
                                  Expanded(
                                    child: Text(
                                      name,
                                      style:
                                          const TextStyle(
                                        fontSize: 14,
                                      ),
                                    ),
                                  ),
                                  SizedBox(
                                    width: 60,
                                    child: Icon(
                                      free
                                          ? Icons.check
                                          : Icons.close,
                                      size: 20,
                                      color: free
                                          ? Colors.green
                                          : colors
                                              .outlineVariant,
                                    ),
                                  ),
                                  SizedBox(
                                    width: 60,
                                    child: Icon(
                                      Icons.check,
                                      size: 20,
                                      color: colors
                                          .primary,
                                    ),
                                  ),
                                ],
                              ),
                            );
                          }),
                        ],
                      ),
                    ),
                    const SizedBox(height: 24),
                    // Pricing
                    Text(
                      '$4.99/month',
                      style: Theme.of(context)
                          .textTheme
                          .headlineSmall
                          ?.copyWith(
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                    Text(
                      'or $39.99/year (save 33%)',
                      style: Theme.of(context)
                          .textTheme
                          .bodyMedium
                          ?.copyWith(
                        color:
                            colors.onSurfaceVariant,
                      ),
                    ),
                  ],
                ),
              ),
            ),
            Padding(
              padding: const EdgeInsets.all(24),
              child: Column(
                children: [
                  FilledButton(
                    onPressed: onSubscribe,
                    style: FilledButton.styleFrom(
                      minimumSize: const Size(
                          double.infinity, 56),
                    ),
                    child: const Text('Go Pro'),
                  ),
                  const SizedBox(height: 8),
                  TextButton(
                    onPressed: onRestore,
                    child: Text(
                      'Restore Purchases',
                      style: TextStyle(
                        color:
                            colors.onSurfaceVariant,
                        fontSize: 12,
                      ),
                    ),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Pattern 3: The Single Focus

One plan, one price, one CTA. No choice paralysis. Works surprisingly well when your value proposition is strong and your trial offer is compelling.

Best for: AI tools, niche utilities, and apps with a single clear value proposition.

Avg conversion: 3-4.5% (top quartile: 5-7% with free trial)

class SingleFocusPaywall extends StatelessWidget {
  final VoidCallback onClose;
  final VoidCallback onSubscribe;
  final VoidCallback onRestore;

  const SingleFocusPaywall({
    super.key,
    required this.onClose,
    required this.onSubscribe,
    required this.onRestore,
  });

  @override
  Widget build(BuildContext context) {
    final colors = Theme.of(context).colorScheme;

    return Scaffold(
      body: SafeArea(
        child: Column(
          children: [
            Align(
              alignment: Alignment.topRight,
              child: IconButton(
                onPressed: onClose,
                icon: const Icon(Icons.close),
              ),
            ),
            const Spacer(),
            Padding(
              padding: const EdgeInsets.symmetric(
                horizontal: 32,
              ),
              child: Column(
                children: [
                  Text(
                    'Unlimited AI generations',
                    style: Theme.of(context)
                        .textTheme
                        .headlineMedium
                        ?.copyWith(
                      fontWeight: FontWeight.bold,
                    ),
                    textAlign: TextAlign.center,
                  ),
                  const SizedBox(height: 12),
                  Text(
                    'Create stunning content, write '
                    'better copy, and brainstorm '
                    'ideas — without limits.',
                    style: Theme.of(context)
                        .textTheme
                        .bodyLarge
                        ?.copyWith(
                      color:
                          colors.onSurfaceVariant,
                    ),
                    textAlign: TextAlign.center,
                  ),
                  const SizedBox(height: 40),
                  _BenefitRow(
                    icon: Icons.auto_awesome,
                    text: 'Unlimited AI generations',
                  ),
                  _BenefitRow(
                    icon: Icons.description,
                    text: 'Advanced writing assistant',
                  ),
                  _BenefitRow(
                    icon: Icons.bolt,
                    text:
                        'Priority processing — no queue',
                  ),
                  _BenefitRow(
                    icon: Icons.download,
                    text: 'Export in full quality',
                  ),
                ],
              ),
            ),
            const Spacer(),
            Padding(
              padding: const EdgeInsets.all(24),
              child: Column(
                children: [
                  FilledButton(
                    onPressed: onSubscribe,
                    style: FilledButton.styleFrom(
                      minimumSize: const Size(
                          double.infinity, 56),
                    ),
                    child: const Column(
                      mainAxisSize: MainAxisSize.min,
                      children: [
                        Text(
                          'Try Free for 7 Days',
                          style: TextStyle(
                            fontWeight:
                                FontWeight.bold,
                          ),
                        ),
                        Text(
                          'then $6.99/month',
                          style: TextStyle(
                            fontSize: 12,
                          ),
                        ),
                      ],
                    ),
                  ),
                  const SizedBox(height: 8),
                  Text(
                    'Cancel anytime. No charge '
                    'during trial.',
                    style: Theme.of(context)
                        .textTheme
                        .bodySmall
                        ?.copyWith(
                      color:
                          colors.onSurfaceVariant,
                    ),
                  ),
                  TextButton(
                    onPressed: onRestore,
                    child: Text(
                      'Restore Purchases',
                      style: TextStyle(
                        color:
                            colors.onSurfaceVariant,
                        fontSize: 12,
                      ),
                    ),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class _BenefitRow extends StatelessWidget {
  final IconData icon;
  final String text;
  const _BenefitRow({
    required this.icon,
    required this.text,
  });

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 16),
      child: Row(
        children: [
          Icon(
            icon,
            color: Theme.of(context)
                .colorScheme
                .primary,
          ),
          const SizedBox(width: 16),
          Text(text,
              style: Theme.of(context)
                  .textTheme
                  .bodyLarge),
        ],
      ),
    );
  }
}

Conversion Benchmarks by Paywall Type

Paywall PatternAvg ConversionTop QuartileBest Trigger
Classic Stack (2-3 plans)3.5-5%6-8%Post-onboarding, feature gate
Comparison Table4-6%7-10%Feature gate (user hit a limit)
Single Focus3-4.5%5-7%Post-onboarding with trial
Full-screen hero2.5-4%5-6%Cold open (first launch)
Bottom sheet / mini paywall2-3%4-5%Inline feature gate

Key insight: The trigger matters as much as the design. A beautiful paywall shown at the wrong time will underperform a basic paywall shown at a high-intent moment. Read our monetization guide for paywall placement strategy.

RevenueCat Integration

Here is how to wire the paywall templates above to RevenueCat for real purchases. For a complete RevenueCat setup tutorial, see our RevenueCat Flutter integration guide.

class PaywallScreen extends StatefulWidget {
  const PaywallScreen({super.key});

  @override
  State<PaywallScreen> createState() =>
      _PaywallScreenState();
}

class _PaywallScreenState extends State<PaywallScreen> {
  List<Package> _packages = [];
  bool _isLoading = true;

  @override
  void initState() {
    super.initState();
    _loadOfferings();
  }

  Future<void> _loadOfferings() async {
    try {
      final offerings =
          await Purchases.getOfferings();
      if (offerings.current != null) {
        setState(() {
          _packages =
              offerings.current!.availablePackages;
          _isLoading = false;
        });
      }
    } catch (e) {
      setState(() => _isLoading = false);
    }
  }

  Future<void> _purchase(Package package) async {
    try {
      final result =
          await Purchases.purchasePackage(package);
      if (result.entitlements.all['pro']?.isActive ??
          false) {
        if (mounted) Navigator.of(context).pop(true);
      }
    } on PlatformException catch (e) {
      final code =
          PurchasesErrorHelper.getErrorCode(e);
      if (code !=
          PurchasesErrorCode.purchaseCancelledError) {
        // Show error to user
        if (mounted) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(
              content: Text('Purchase failed: $e'),
            ),
          );
        }
      }
    }
  }

  Future<void> _restore() async {
    try {
      final info =
          await Purchases.restorePurchases();
      if (info.entitlements.all['pro']?.isActive ??
          false) {
        if (mounted) Navigator.of(context).pop(true);
      }
    } catch (e) {
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(
            content: Text(
              'No purchases to restore.',
            ),
          ),
        );
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    if (_isLoading) {
      return const Scaffold(
        body: Center(
          child: CircularProgressIndicator(),
        ),
      );
    }

    return ClassicStackPaywall(
      packages: _packages,
      onClose: () => Navigator.of(context).pop(),
      onPurchase: _purchase,
      onRestore: _restore,
    );
  }
}

A/B Testing Your Paywall

Your first paywall will not be your best paywall. A/B testing is how you systematically improve conversion. Here is what to test, in priority order:

  1. Price point — Test $3.99/mo vs $5.99/mo vs $7.99/mo. Higher prices sometimes convert better because they signal quality.
  2. Trial length — 3-day vs 7-day. Shorter trials have higher trial-to-paid conversion but fewer trial starts.
  3. Headline copy — Benefit-focused vs feature-focused vs social proof-focused.
  4. Number of plans — Two plans vs three. Sometimes fewer choices convert better.
  5. Design pattern — Classic Stack vs Comparison Table vs Single Focus.

RevenueCat Experiments lets you run server-side A/B tests. Create two offerings, split traffic 50/50, and RevenueCat tracks conversion rate and revenue per user. Aim for 2,000+ impressions per variant before declaring a winner.

Cross-Platform Considerations

With Flutter, your paywall runs on both iOS and Android — but user behavior differs:

  • iOS users are more accustomed to subscription paywalls and spend more on average. Annual plans convert well on iOS.
  • Android users may respond better to lower price points or lifetime purchases. Consider offering a lifetime option on Android that you do not show on iOS.
  • Apple is stricter about paywall UX during app review. Always include a clear close button and transparent trial terms.
  • Google Play requires a clear data safety section. Subscription terms must be visible before the purchase dialog.

Use RevenueCat Experiments to A/B test different offerings per platform. You can show different prices, trial lengths, or even different paywall designs on iOS vs Android.

Get All 3 Paywall Patterns Pre-Built

Building, testing, and iterating on paywalls takes significant development time. The Flutter Kit includes all three paywall patterns — Classic Stack, Comparison Table, and Single Focus — fully integrated with RevenueCat, supporting both iOS and Android out of the box.

Each template uses your Material 3 design tokens, supports dark mode, handles loading states, and includes full purchase and restore flows. Swap in your RevenueCat API key, configure offerings, and you have paywalls that convert from day one.

Share this article

Ready to ship your Flutter app faster?

The Flutter Kit gives you a production-ready Flutter codebase with onboarding, paywalls, auth, AI integrations, and more. Stop building boilerplate. Start building your product.

Get The Flutter Kit