Create a membership site by adding a subscriptionTier field to your users collection, gating content pages with an On Page Load tier check that shows a paywall overlay for unauthorized users, processing payments through a Stripe Checkout subscription Cloud Function, and automatically updating tiers via a Stripe webhook. The content collection uses a requiredTier field so Backend Queries only return items the user can access.
Building a Content Subscription System in FlutterFlow
Membership sites let you monetize exclusive content by restricting access to paying subscribers. This tutorial walks through the full system: tier-based user fields, a visually appealing paywall overlay, Stripe subscription payments via Cloud Functions, webhook-driven tier management, and a filtered content library that only shows items the user is entitled to view.
Prerequisites
- A FlutterFlow project on the Pro plan or higher
- Firebase project with Firestore and Authentication enabled
- A Stripe account with at least one subscription Price created in the Stripe Dashboard
- Basic familiarity with Firestore collections and FlutterFlow Backend Queries
Step-by-step guide
Add subscription fields to the users collection
Add subscription fields to the users collection
Open the Firestore schema in FlutterFlow and add three fields to your users document: subscriptionTier (String, default 'free'), subscriptionExpiresAt (Timestamp, nullable), and stripeCustomerId (String, nullable). The subscriptionTier field will hold values like 'free', 'basic', or 'premium'. Create a content collection with fields: title (String), body (String), thumbnailUrl (String), requiredTier (String), and createdAt (Timestamp). This separation lets you gate individual content items by tier.
Expected result: Your users collection has subscriptionTier, subscriptionExpiresAt, and stripeCustomerId fields. The content collection has a requiredTier field on every document.
Build the content library page with tier-filtered Backend Query
Build the content library page with tier-filtered Backend Query
Create a new page called ContentLibrary. Add a Backend Query on a ListView that queries the content collection. Add a filter where requiredTier is less than or equal to the current user's subscriptionTier using a custom ordering (free=0, basic=1, premium=2 via a Custom Function that maps tier strings to integers). For a simpler approach, query all content and use Conditional Visibility on each list item to show a lock icon overlay on items above the user's tier. Display each item as a Container with the thumbnail, title, and a lock icon Stack overlay for gated items.
Expected result: The content library displays all items with unlocked content fully visible and locked content showing a lock icon overlay on the thumbnail.
Create the paywall overlay with blur and upgrade CTA
Create the paywall overlay with blur and upgrade CTA
On the content detail page, wrap the main content Column in a Stack. Add a second child to the Stack: a Container with a BackdropFilter (Custom Widget using ImageFilter.blur with sigmaX and sigmaY set to 8) that covers the full page. Inside this blurred overlay, add a Column with a lock Icon, a Text widget reading 'Premium Content', a description of what the tier includes, and a Button labeled 'Upgrade Now'. Set Conditional Visibility on the blur Container to show only when currentUserDocument.subscriptionTier is less than the content's requiredTier.
Expected result: Free users who navigate to a premium content page see a blurred overlay with an Upgrade Now button. Subscribed users see the full content without any overlay.
Set up the Stripe Checkout subscription Cloud Function
Set up the Stripe Checkout subscription Cloud Function
Create a Cloud Function called createSubscriptionCheckout. It receives the userId and priceId as parameters. The function looks up or creates a Stripe Customer (storing stripeCustomerId on the user doc), then calls stripe.checkout.sessions.create with mode set to 'subscription', the priceId, success and cancel URLs pointing back to your app, and customer set to the Stripe customer ID. Return the session URL. In FlutterFlow, wire the Upgrade Now button to call this Cloud Function via an API Call action, then use a Launch URL action with the returned session URL to open the Stripe Checkout page.
1// Cloud Function: createSubscriptionCheckout2const functions = require('firebase-functions');3const stripe = require('stripe')(functions.config().stripe.secret_key);4const admin = require('firebase-admin');5admin.initializeApp();67exports.createSubscriptionCheckout = functions.https.onCall(async (data, context) => {8 const { priceId } = data;9 const uid = context.auth.uid;10 const userDoc = await admin.firestore().collection('users').doc(uid).get();11 let customerId = userDoc.data().stripeCustomerId;1213 if (!customerId) {14 const customer = await stripe.customers.create({15 email: userDoc.data().email,16 metadata: { firebaseUID: uid },17 });18 customerId = customer.id;19 await admin.firestore().collection('users').doc(uid).update({ stripeCustomerId: customerId });20 }2122 const session = await stripe.checkout.sessions.create({23 mode: 'subscription',24 customer: customerId,25 line_items: [{ price: priceId, quantity: 1 }],26 success_url: 'https://yourapp.com/subscription-success',27 cancel_url: 'https://yourapp.com/pricing',28 });2930 return { url: session.url };31});Expected result: Tapping Upgrade Now opens Stripe Checkout in the browser where the user completes subscription payment.
Handle Stripe webhook to update user tier automatically
Handle Stripe webhook to update user tier automatically
Create a second Cloud Function called stripeWebhook that listens for Stripe webhook events. Verify the webhook signature using stripe.webhooks.constructEvent. Handle the checkout.session.completed event: extract the customer ID, look up the user by stripeCustomerId, determine the tier from the priceId metadata, and update the user's subscriptionTier and subscriptionExpiresAt fields. Also handle customer.subscription.deleted to revert the user to the free tier. Register this webhook URL in your Stripe Dashboard under Developers > Webhooks.
1// Cloud Function: stripeWebhook2exports.stripeWebhook = functions.https.onRequest(async (req, res) => {3 const sig = req.headers['stripe-signature'];4 let event;5 try {6 event = stripe.webhooks.constructEvent(req.rawBody, sig, functions.config().stripe.webhook_secret);7 } catch (err) {8 return res.status(400).send(`Webhook Error: ${err.message}`);9 }1011 if (event.type === 'checkout.session.completed') {12 const session = event.data.object;13 const subscription = await stripe.subscriptions.retrieve(session.subscription);14 const priceId = subscription.items.data[0].price.id;15 const tier = priceId === 'price_premium_id' ? 'premium' : 'basic';16 const usersRef = admin.firestore().collection('users');17 const snapshot = await usersRef.where('stripeCustomerId', '==', session.customer).get();18 if (!snapshot.empty) {19 await snapshot.docs[0].ref.update({20 subscriptionTier: tier,21 subscriptionExpiresAt: admin.firestore.Timestamp.fromMillis(subscription.current_period_end * 1000),22 });23 }24 }2526 if (event.type === 'customer.subscription.deleted') {27 const subscription = event.data.object;28 const usersRef = admin.firestore().collection('users');29 const snapshot = await usersRef.where('stripeCustomerId', '==', subscription.customer).get();30 if (!snapshot.empty) {31 await snapshot.docs[0].ref.update({ subscriptionTier: 'free', subscriptionExpiresAt: null });32 }33 }3435 res.status(200).send('OK');36});Expected result: After a successful Stripe payment, the user's subscriptionTier field updates automatically. Cancellations revert the user to the free tier.
Add expiry checking and Stripe Customer Portal link
Add expiry checking and Stripe Customer Portal link
On every protected page, add an On Page Load action that checks whether currentUserDocument.subscriptionExpiresAt is before the current timestamp. If expired, update subscriptionTier to 'free' via an Update Document action and show the paywall. For managing subscriptions, add a Manage Subscription button on the settings page. Create a Cloud Function called createPortalSession that calls stripe.billingPortal.sessions.create with the user's stripeCustomerId and returns the portal URL. Wire the button to call this function and open the returned URL, allowing users to cancel or change plans through Stripe's hosted portal.
Expected result: Expired subscriptions are caught on each page load. Users can manage, upgrade, or cancel their subscription through the Stripe Customer Portal.
Complete working example
1// Cloud Function: Full Subscription Management2// Includes checkout creation, webhook handling, and portal session34const functions = require('firebase-functions');5const stripe = require('stripe')(functions.config().stripe.secret_key);6const admin = require('firebase-admin');7admin.initializeApp();89// Create Stripe Checkout for subscription10exports.createSubscriptionCheckout = functions.https.onCall(async (data, context) => {11 if (!context.auth) throw new functions.https.HttpsError('unauthenticated', 'Login required');12 const { priceId } = data;13 const uid = context.auth.uid;14 const userDoc = await admin.firestore().collection('users').doc(uid).get();15 let customerId = userDoc.data().stripeCustomerId;1617 if (!customerId) {18 const customer = await stripe.customers.create({19 email: userDoc.data().email,20 metadata: { firebaseUID: uid },21 });22 customerId = customer.id;23 await admin.firestore().collection('users').doc(uid).update({ stripeCustomerId: customerId });24 }2526 const session = await stripe.checkout.sessions.create({27 mode: 'subscription',28 customer: customerId,29 line_items: [{ price: priceId, quantity: 1 }],30 success_url: 'https://yourapp.com/subscription-success',31 cancel_url: 'https://yourapp.com/pricing',32 });33 return { url: session.url };34});3536// Stripe Webhook Handler37exports.stripeWebhook = functions.https.onRequest(async (req, res) => {38 const sig = req.headers['stripe-signature'];39 let event;40 try {41 event = stripe.webhooks.constructEvent(42 req.rawBody, sig, functions.config().stripe.webhook_secret43 );44 } catch (err) {45 return res.status(400).send(`Webhook Error: ${err.message}`);46 }4748 const usersRef = admin.firestore().collection('users');4950 if (event.type === 'checkout.session.completed') {51 const session = event.data.object;52 const subscription = await stripe.subscriptions.retrieve(session.subscription);53 const priceId = subscription.items.data[0].price.id;54 const tier = priceId === 'price_premium_id' ? 'premium' : 'basic';55 const snapshot = await usersRef.where('stripeCustomerId', '==', session.customer).get();56 if (!snapshot.empty) {57 await snapshot.docs[0].ref.update({58 subscriptionTier: tier,59 subscriptionExpiresAt: admin.firestore.Timestamp.fromMillis(60 subscription.current_period_end * 100061 ),62 });63 }64 }6566 if (event.type === 'customer.subscription.deleted') {67 const sub = event.data.object;68 const snapshot = await usersRef.where('stripeCustomerId', '==', sub.customer).get();69 if (!snapshot.empty) {70 await snapshot.docs[0].ref.update({ subscriptionTier: 'free', subscriptionExpiresAt: null });71 }72 }73 res.status(200).send('OK');74});7576// Customer Portal Session77exports.createPortalSession = functions.https.onCall(async (data, context) => {78 if (!context.auth) throw new functions.https.HttpsError('unauthenticated', 'Login required');79 const uid = context.auth.uid;80 const userDoc = await admin.firestore().collection('users').doc(uid).get();81 const customerId = userDoc.data().stripeCustomerId;82 const session = await stripe.billingPortal.sessions.create({83 customer: customerId,84 return_url: 'https://yourapp.com/settings',85 });86 return { url: session.url };87});Common mistakes
Why it's a problem: Checking subscription status only on login instead of every protected page load
How to avoid: Add an On Page Load action on every protected page that compares subscriptionExpiresAt against the current timestamp and downgrades expired users immediately.
Why it's a problem: Gating content only in the UI without Firestore Security Rules
How to avoid: Add Firestore Security Rules that check the requesting user's subscriptionTier against the content document's requiredTier before allowing reads.
Why it's a problem: Crediting the subscription tier optimistically before Stripe confirms payment
How to avoid: Only update subscriptionTier inside the Stripe webhook handler after receiving a confirmed checkout.session.completed event.
Why it's a problem: Hardcoding Stripe price IDs in FlutterFlow instead of storing them in Firestore
How to avoid: Store price IDs in a Firestore pricing_tiers collection and read them dynamically so tier changes take effect without an app update.
Best practices
- Store Stripe API keys in Cloud Function environment config, never in client-side code
- Use Stripe's hosted Customer Portal for subscription management instead of building your own cancellation flow
- Add a subscriptionExpiresAt field and check it on every protected page load for real-time enforcement
- Show a content teaser (first paragraph or blurred preview) before the paywall to motivate upgrades
- Log all tier changes in a subscription_events collection for auditing and debugging
- Use Stripe's test mode with test card 4242 4242 4242 4242 to verify the full flow before going live
- Implement graceful degradation: if the tier check fails due to a network error, default to the cached tier rather than locking the user out
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I need to build a membership site in FlutterFlow with Stripe subscriptions. Show me the Firestore schema for users with subscription tiers, a content collection with tier gating, the Cloud Function for creating a Stripe Checkout subscription session, and the webhook handler that updates the user's tier on payment success.
Create a content library page where each item shows a lock icon if the user's subscription tier is below the content's required tier. Add a paywall overlay with a blurred background and an Upgrade Now button that opens Stripe Checkout.
Frequently asked questions
Can I offer both monthly and annual subscription options?
Yes. Create separate Price objects in Stripe (one monthly, one annual) and display both options on your pricing page. Pass the selected priceId to the createSubscriptionCheckout Cloud Function.
How do I handle free trial periods before charging?
Add trial_period_days to your Stripe Checkout session creation. Set the user's tier to the trial tier immediately and let the webhook handle conversion or expiry when the trial ends.
What happens if the Stripe webhook fails to update the user document?
Stripe retries failed webhooks for up to 3 days. Add error logging in your Cloud Function and consider a scheduled function that reconciles Stripe subscription statuses with your Firestore user documents daily.
Can I gate specific features instead of entire content pages?
Yes. Use Conditional Visibility on any widget, checking currentUserDocument.subscriptionTier. You can gate buttons, sections, or entire pages using the same tier comparison logic.
How do I prevent users from sharing their account to bypass the paywall?
Track active sessions in Firestore and limit concurrent logins. On each login, check if another session exists and force-logout the previous one, similar to how streaming services handle single-screen plans.
Can RapidDev help build a production membership platform?
Yes. RapidDev can implement the full subscription system including multi-tier pricing, Stripe integration, webhook handling, content gating, and analytics dashboards for subscriber metrics.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation