Skip to main content
RapidDev - Software Development Agency

How to Build a Membership Site with Replit

Build a membership site in Replit in 1-2 hours. Use Replit Agent to generate an Express + PostgreSQL app with tiered content access, Stripe subscription billing, and a middleware that checks member tier before serving protected content. Deploy on Autoscale.

What you'll build

  • Tiered membership system with Bronze (free), Silver, Gold content access levels
  • Stripe subscription billing for paid tiers using the /stripe Replit command for quick setup
  • Protected content library with lock/unlock icons based on member tier
  • Access middleware that compares member tier display_order to content min_tier_order before serving
  • Content access logging to member_content_access for engagement tracking
  • Stripe Customer Portal integration so members manage their own billing
  • Webhook handler for subscription lifecycle events (created, updated, deleted)
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate14 min read1-2 hoursReplit FreeApril 2026RapidDev Engineering Team
TL;DR

Build a membership site in Replit in 1-2 hours. Use Replit Agent to generate an Express + PostgreSQL app with tiered content access, Stripe subscription billing, and a middleware that checks member tier before serving protected content. Deploy on Autoscale.

What you're building

A membership site gates your premium content, courses, or community behind a paywall. Instead of selling content one piece at a time, you sell access levels — members pay a monthly fee to unlock everything in their tier. Patreon, Substack, and course platforms all use this model. You can build your own version with full control over pricing, content, and the member experience.

Replit Agent generates the full Express backend with the tiered access system already designed. The key insight is using a display_order column on membership_tiers to represent the tier hierarchy: Bronze is 0, Silver is 1, Gold is 2. When checking access, you compare the member's tier display_order against the content's min_tier display_order — a Gold member can access Silver and Bronze content because 2 >= 1. This single comparison handles the entire tiered access logic.

Use the Replit /stripe command to quickly provision the Stripe integration — it auto-creates a sandbox, sets up routes, and gives you test keys. You'll then extend it with subscription-specific routes for tier selection and the Customer Portal. Webhooks update member status when subscriptions are created, upgraded, or cancelled. Deploy on Autoscale — membership sites have regular but not constant traffic.

Final result

A fully functional membership site with free and paid tiers, tiered content access middleware, Stripe subscription billing, Customer Portal, and a content library with blurred previews for locked content — deployed on Replit Autoscale.

Tech stack

ReplitIDE & Hosting
ExpressBackend Framework
PostgreSQLDatabase
Drizzle ORMDatabase ORM
StripeSubscription Billing
Replit AuthAuth

Prerequisites

  • A Replit account (Free plan is sufficient for development)
  • A list of content items you want to gate (articles, videos, downloads, courses)
  • Tier names and monthly prices (e.g., Free: $0, Silver: $9/mo, Gold: $29/mo)
  • A Stripe account — the /stripe command will create test keys automatically

Build steps

1

Scaffold the project and set up Stripe with the /stripe command

Create a new Repl, use the Agent prompt to generate the membership site structure, then run the /stripe command in the Replit chat to auto-provision Stripe test keys and routes.

prompt.txt
1// Step 1: Type this into Replit Agent:
2// Build a membership site with Express and PostgreSQL using Drizzle ORM.
3// Tables:
4// - membership_tiers: id serial pk, name text not null, description text,
5// price integer not null (cents per month, 0 for free), stripe_price_id text unique,
6// features jsonb, max_members integer, display_order integer default 0,
7// is_active boolean default true
8// - members: id serial pk, user_id text unique not null, email text not null, name text,
9// tier_id integer FK membership_tiers not null, stripe_customer_id text,
10// stripe_subscription_id text, status text default 'active'
11// (enum: active/expired/cancelled/paused), joined_at timestamp default now(),
12// expires_at timestamp
13// - protected_content: id serial pk, title text not null, body text not null,
14// min_tier_id integer FK membership_tiers not null,
15// content_type text default 'article' (enum: article/video/download/course),
16// file_url text, published_at timestamp, created_at timestamp default now()
17// - member_content_access: id serial pk, member_id integer FK members,
18// content_id integer FK protected_content, accessed_at timestamp default now()
19// - webhook_events: id serial pk, stripe_event_id text unique, event_type text,
20// processed_at timestamp default now()
21// Routes: GET /api/tiers, POST /api/members/join, GET /api/members/me,
22// POST /api/billing-portal, GET /api/content, GET /api/content/:id,
23// POST /api/admin/content, GET /api/admin/members, POST /api/webhooks/stripe.
24// Replit Auth. Bind server to 0.0.0.0.
25
26// Step 2: After Agent creates the project, type in Replit chat:
27// /stripe
28// This provisions Stripe test sandbox, adds STRIPE_SECRET_KEY and
29// STRIPE_PUBLISHABLE_KEY to Secrets, and creates basic checkout routes.

Pro tip: After running /stripe, you'll need to extend the routes for subscription-specific flows (recurring billing + Customer Portal). The /stripe command sets up a one-time payment checkout — subscriptions require additional code in the steps below.

Expected result: A running Express app with all tables. Replit Secrets shows STRIPE_SECRET_KEY and STRIPE_PUBLISHABLE_KEY. The console shows the app is running on port 5000.

2

Seed the membership tiers and add Stripe Price IDs

Create your tier records in the database and link each paid tier to a Stripe Price ID. Create recurring prices in the Stripe Dashboard, then store the price IDs in the membership_tiers table.

scripts/seed-tiers.js
1// scripts/seed-tiers.js — run once to create membership tiers
2const { db } = require('../server/db');
3const { membershipTiers } = require('./shared/schema');
4
5async function seedTiers() {
6 // Create tiers in order (display_order determines access hierarchy)
7 await db.insert(membershipTiers).values([
8 {
9 name: 'Free',
10 description: 'Access to free content',
11 price: 0,
12 stripePriceId: null, // No Stripe price for free tier
13 features: JSON.stringify(['Access to free articles', 'Weekly newsletter']),
14 displayOrder: 0,
15 isActive: true,
16 },
17 {
18 name: 'Silver',
19 description: 'Access to Silver and Free content',
20 price: 900, // $9/month in cents
21 // Get this from Stripe Dashboard → Products → Create a product → Add price (recurring)
22 stripePriceId: process.env.STRIPE_SILVER_PRICE_ID,
23 features: JSON.stringify(['Everything in Free', 'Premium articles', 'Monthly webinars']),
24 displayOrder: 1,
25 isActive: true,
26 },
27 {
28 name: 'Gold',
29 description: 'Access to all content',
30 price: 2900, // $29/month in cents
31 stripePriceId: process.env.STRIPE_GOLD_PRICE_ID,
32 features: JSON.stringify(['Everything in Silver', 'Video courses', 'Direct Q&A access', '1-on-1 calls']),
33 displayOrder: 2,
34 isActive: true,
35 },
36 ]).onConflictDoNothing();
37
38 console.log('Tiers seeded. Add STRIPE_SILVER_PRICE_ID and STRIPE_GOLD_PRICE_ID to Replit Secrets.');
39 process.exit(0);
40}
41
42seedTiers().catch(console.error);

Pro tip: In Stripe Dashboard → Products → Create a product for each paid tier. Add a recurring price (monthly). Copy the price ID (starts with 'price_') and add it to Replit Secrets as STRIPE_SILVER_PRICE_ID and STRIPE_GOLD_PRICE_ID.

3

Build the content access middleware

This is the core of the membership site. The requireTier middleware loads the member's current tier, compares display_order values, and returns a 403 with an upgrade prompt if the member's tier is below the content's minimum tier.

server/middleware/membership.js
1const { db } = require('../db');
2const { members, membershipTiers } = require('../../shared/schema');
3const { eq } = require('drizzle-orm');
4
5// Middleware: checks member tier against required minimum tier
6exports.requireTier = (minTierOrder) => async (req, res, next) => {
7 const userId = req.user?.id;
8 if (!userId) {
9 return res.status(401).json({ error: 'Login required', loginUrl: '/login' });
10 }
11
12 const result = await db
13 .select({
14 memberId: members.id,
15 tierOrder: membershipTiers.displayOrder,
16 tierName: membershipTiers.name,
17 status: members.status,
18 })
19 .from(members)
20 .innerJoin(membershipTiers, eq(members.tierId, membershipTiers.id))
21 .where(eq(members.userId, userId));
22
23 if (!result[0]) {
24 return res.status(403).json({
25 error: 'No active membership',
26 upgradeUrl: '/pricing',
27 });
28 }
29
30 const member = result[0];
31
32 if (member.status !== 'active') {
33 return res.status(403).json({
34 error: 'Membership expired or cancelled',
35 upgradeUrl: '/billing',
36 });
37 }
38
39 if (member.tierOrder < minTierOrder) {
40 return res.status(403).json({
41 error: `This content requires a higher membership tier`,
42 currentTier: member.tierName,
43 upgradeUrl: '/pricing',
44 });
45 }
46
47 // Attach member info to request for use in route handlers
48 req.member = member;
49 next();
50};
51
52// GET /api/content/:id — protected content with access logging
53exports.getContent = async (req, res) => {
54 const { protectedContent, memberContentAccess } = require('../../shared/schema');
55 const [content] = await db.select().from(protectedContent)
56 .where(eq(protectedContent.id, parseInt(req.params.id)));
57
58 if (!content) return res.status(404).json({ error: 'Content not found' });
59
60 // Log access
61 await db.insert(memberContentAccess).values({
62 memberId: req.member.memberId,
63 contentId: content.id,
64 }).onConflictDoNothing();
65
66 res.json(content);
67};

Expected result: GET /api/content/5 with a Gold member's token succeeds. The same request with a Free member's token returns 403 with an upgradeUrl pointing to the pricing page.

4

Build the subscription checkout and Customer Portal

The join route creates a Stripe Checkout Session for recurring subscriptions. The billing-portal route gives members a self-service link to change or cancel their plan.

server/routes/membership.js
1const Stripe = require('stripe');
2const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
3const { members, membershipTiers } = require('../../shared/schema');
4const { eq } = require('drizzle-orm');
5
6// POST /api/members/join — start subscription checkout
7router.post('/members/join', async (req, res) => {
8 const userId = req.user?.id;
9 if (!userId) return res.status(401).json({ error: 'Login required' });
10
11 const { tierId } = req.body;
12 const [tier] = await db.select().from(membershipTiers).where(eq(membershipTiers.id, tierId));
13 if (!tier) return res.status(404).json({ error: 'Tier not found' });
14
15 // Free tier: create member directly without Stripe
16 if (tier.price === 0) {
17 await db.insert(members).values({
18 userId,
19 email: req.user.email || '',
20 tierId: tier.id,
21 status: 'active',
22 }).onConflictDoNothing();
23 return res.json({ redirect: '/dashboard' });
24 }
25
26 // Paid tier: create Stripe Checkout Session for subscription
27 const session = await stripe.checkout.sessions.create({
28 payment_method_types: ['card'],
29 mode: 'subscription',
30 line_items: [{ price: tier.stripePriceId, quantity: 1 }],
31 success_url: `${process.env.REPLIT_DEPLOYMENT_URL}/dashboard?welcome=1`,
32 cancel_url: `${process.env.REPLIT_DEPLOYMENT_URL}/pricing`,
33 metadata: { userId, tierId: tier.id.toString() },
34 });
35
36 res.json({ checkoutUrl: session.url });
37});
38
39// POST /api/billing-portal — Stripe Customer Portal
40router.post('/billing-portal', async (req, res) => {
41 const userId = req.user?.id;
42 const [member] = await db.select().from(members).where(eq(members.userId, userId));
43 if (!member?.stripeCustomerId) {
44 return res.status(400).json({ error: 'No billing information found' });
45 }
46
47 const portalSession = await stripe.billingPortal.sessions.create({
48 customer: member.stripeCustomerId,
49 return_url: `${process.env.REPLIT_DEPLOYMENT_URL}/dashboard`,
50 });
51
52 res.json({ url: portalSession.url });
53});

Expected result: POST /api/members/join with a paid tier returns a Stripe Checkout URL for monthly subscription. After payment, the webhook creates the member record and grants access.

5

Add the Stripe webhook handler and deploy on Autoscale

The webhook handles three subscription events: completed (create member), updated (change tier), and deleted (expire member). Deploy on Autoscale after adding STRIPE_WEBHOOK_SECRET to Secrets.

server/routes/webhooks.js
1// POST /api/webhooks/stripe — handle subscription lifecycle
2// Register this route BEFORE express.json() in server/index.js
3router.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
4 const sig = req.headers['stripe-signature'];
5 let event;
6
7 try {
8 event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
9 } catch (err) {
10 return res.status(400).send(`Webhook Error: ${err.message}`);
11 }
12
13 const existing = await db.select().from(webhookEvents)
14 .where(eq(webhookEvents.stripeEventId, event.id));
15 if (existing.length > 0) return res.json({ received: true });
16 await db.insert(webhookEvents).values({ stripeEventId: event.id, eventType: event.type });
17
18 if (event.type === 'checkout.session.completed') {
19 const session = event.data.object;
20 if (session.mode !== 'subscription') return res.json({ received: true });
21
22 const { userId, tierId } = session.metadata;
23 await db.insert(members).values({
24 userId,
25 email: session.customer_details?.email || '',
26 tierId: parseInt(tierId),
27 stripeCustomerId: session.customer,
28 stripeSubscriptionId: session.subscription,
29 status: 'active',
30 }).onConflictDoUpdate({
31 target: members.userId,
32 set: { tierId: parseInt(tierId), status: 'active', stripeSubscriptionId: session.subscription },
33 });
34 }
35
36 if (event.type === 'customer.subscription.deleted') {
37 const subscription = event.data.object;
38 await db.update(members)
39 .set({ status: 'expired' })
40 .where(eq(members.stripeSubscriptionId, subscription.id));
41 }
42
43 res.json({ received: true });
44});

Pro tip: After deploying on Autoscale, go to Stripe Dashboard → Developers → Webhooks → Add endpoint. Enter your Replit URL + /api/webhooks/stripe. Select events: checkout.session.completed, customer.subscription.updated, customer.subscription.deleted. Copy the Signing Secret and add it to Replit Secrets as STRIPE_WEBHOOK_SECRET.

Expected result: Completing a test subscription checkout triggers the webhook, creates the member record, and grants tier-appropriate content access.

Complete code

server/middleware/membership.js
1const { db } = require('../db');
2const { members, membershipTiers, memberContentAccess } = require('../../shared/schema');
3const { eq } = require('drizzle-orm');
4
5// Middleware factory: requireTier(minOrder) checks if member's tier is >= minOrder
6exports.requireTier = (minTierOrder) => async (req, res, next) => {
7 const userId = req.user?.id;
8 if (!userId) return res.status(401).json({ error: 'Login required', loginUrl: '/login' });
9
10 const result = await db
11 .select({
12 memberId: members.id,
13 tierOrder: membershipTiers.displayOrder,
14 tierName: membershipTiers.name,
15 status: members.status,
16 })
17 .from(members)
18 .innerJoin(membershipTiers, eq(members.tierId, membershipTiers.id))
19 .where(eq(members.userId, userId));
20
21 if (!result[0]) return res.status(403).json({ error: 'No active membership', upgradeUrl: '/pricing' });
22
23 const member = result[0];
24 if (member.status !== 'active') return res.status(403).json({ error: 'Membership expired', upgradeUrl: '/billing' });
25 if (member.tierOrder < minTierOrder) {
26 return res.status(403).json({ error: 'Upgrade required', currentTier: member.tierName, upgradeUrl: '/pricing' });
27 }
28
29 req.member = member;
30 next();
31};
32
33// List content with lock/unlock metadata for current member
34exports.listContent = async (req, res) => {
35 const userId = req.user?.id;
36 const memberResult = userId ? await db
37 .select({ tierOrder: membershipTiers.displayOrder })
38 .from(members)
39 .innerJoin(membershipTiers, eq(members.tierId, membershipTiers.id))
40 .where(eq(members.userId, userId)) : [];
41
42 const memberTierOrder = memberResult[0]?.tierOrder ?? -1;
43
44 const { protectedContent } = require('../../shared/schema');
45 const contentList = await db
46 .select({
47 id: protectedContent.id,
48 title: protectedContent.title,
49 contentType: protectedContent.contentType,
50 publishedAt: protectedContent.publishedAt,
51 minTierOrder: membershipTiers.displayOrder,
52 minTierName: membershipTiers.name,
53 })
54 .from(protectedContent)
55 .innerJoin(membershipTiers, eq(protectedContent.minTierId, membershipTiers.id))
56 .where(eq(protectedContent.publishedAt, protectedContent.publishedAt)) // active content only
57 .orderBy(protectedContent.publishedAt);
58
59 const withAccess = contentList.map(c => ({
60 ...c,
61 isLocked: memberTierOrder < c.minTierOrder,
62 }));
63
64 res.json(withAccess);
65};

Customization ideas

Content drip scheduling

Add a publish_days_after_join column to protected_content. New members get access to content gradually based on how long they've been members. The content list query filters by member.joined_at + publish_days_after_join <= now().

Annual billing discount

Create annual Stripe prices (e.g., $90/year for Silver instead of $9/month). Add a billing_period toggle to the pricing page. Pass the annual price ID to the Checkout Session. Members with annual plans get a locked_until date set to one year from now.

Member community

Add a community_posts table gated behind membership. Silver members can read and comment; Gold members can start new threads. Use the requireTier middleware with different minimum tier orders for read vs create routes.

Affiliate referral program

Give each member a unique referral code. Track referrals in a referrals table. When a referred user subscribes, credit the referrer with one month free using a Stripe coupon API call.

Common pitfalls

Pitfall: Using points_balance or a boolean for tier access instead of display_order

How to avoid: Use a single integer display_order on membership_tiers. The access check is simply: member.tier.display_order >= content.min_tier.display_order. Adding a new tier just requires inserting a new row with the right display_order.

Pitfall: Registering the webhook route after express.json()

How to avoid: Register app.use('/api/webhooks', webhooksRouter) before app.use(express.json()) in server/index.js. Use express.raw({ type: 'application/json' }) only on the webhook route itself.

Pitfall: Creating member records before the Stripe payment confirms

How to avoid: Create the member record only inside the checkout.session.completed webhook handler. Store the userId and tierId in the Stripe session metadata to recover them in the webhook.

Pitfall: Forgetting to handle subscription downgrades in the webhook

How to avoid: In the customer.subscription.updated handler, match the new price_id to a membership_tier and update the member's tier_id accordingly.

Best practices

  • Use display_order on membership_tiers for the access hierarchy — a single integer comparison handles all tier levels elegantly.
  • Store STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_SILVER_PRICE_ID, STRIPE_GOLD_PRICE_ID, and REPLIT_DEPLOYMENT_URL in Replit Secrets.
  • Create member records only in the checkout.session.completed webhook handler, never on checkout session creation.
  • Use the /stripe Replit command to get Stripe test keys quickly, then extend the routes manually for subscription-specific flows.
  • Expose lock/unlock status in the GET /api/content list response so the frontend can show blurred previews and upgrade CTAs without making per-item access checks.
  • Log every content access in member_content_access — this data reveals which content drives upgrades and which isn't worth gating.
  • Deploy on Autoscale for membership sites. The webhook endpoint needs to be reachable, and Replit's Autoscale wakes up quickly enough for webhook delivery.

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building a membership site with Express and PostgreSQL. I have a membership_tiers table with a display_order integer column (Free=0, Silver=1, Gold=2) and a protected_content table with a min_tier_id foreign key. Help me write an Express middleware function requireTier(minTierOrder) that: (1) checks if the user is logged in, (2) queries the members table joined to membership_tiers to get the current member's tier display_order, (3) returns 403 with an upgradeUrl if their tier order is less than minTierOrder, and (4) attaches the member info to req.member for use in route handlers.

Build Prompt

Add a content drip system to the membership site. Add a release_days_after_join integer column to protected_content (NULL = immediately available, 7 = available 7 days after joining). Modify the GET /api/content/:id route: after the tier check, calculate the content release date as member.joined_at + release_days_after_join days. If today is before the release date, return 403 with a message saying when the content unlocks. In the GET /api/content list response, include a release_date field and an is_released boolean so the frontend can show a countdown timer on locked-but-upcoming content.

Frequently asked questions

How does tiered content access actually work?

Each tier has a display_order integer (Free=0, Silver=1, Gold=2). Each content item has a min_tier_id linking to the minimum required tier. The access check is: member.tier.display_order >= content.min_tier.display_order. A Gold member (order=2) can access Silver content (order=1) because 2 >= 1. Adding a new Platinum tier just requires inserting a row with display_order=3.

What happens to a member's access if they cancel their subscription?

Stripe sends a customer.subscription.deleted webhook event. The handler updates the member's status to 'expired'. The requireTier middleware returns 403 with an upgradeUrl for any content access attempt. The member's data is preserved — they just lose access until they resubscribe.

Can free-tier members browse without logging in?

Yes — modify the content list route to skip the member check and mark all content with min_tier_order > 0 as isLocked: true. Only the detail route (GET /api/content/:id) requires the requireTier middleware. Free visitors see titles and descriptions, locked content shows a blurred preview.

What Replit plan do I need?

The Free plan is sufficient for development. For public deployment, Autoscale works well for membership sites (Core plan or higher). Unlike marketplaces, membership webhook events (subscription lifecycle) are less time-sensitive — Replit's fast Autoscale startup handles them reliably.

How do I set up Stripe Price IDs for the tiers?

In Stripe Dashboard → Products → Create a product for each paid tier. Add a recurring price (e.g., $9/month for Silver). Copy the price ID (starts with 'price_'). Add it to Replit Secrets as STRIPE_SILVER_PRICE_ID and update your membership_tiers row's stripe_price_id column via Drizzle Studio.

How does the Customer Portal work?

POST /api/billing-portal creates a Stripe-hosted Customer Portal session linked to the member's Stripe customer ID. Redirecting the member to the returned URL shows them a Stripe-hosted page where they can update their payment method, change plans, or cancel — all without you building a billing UI.

Can RapidDev help build a custom membership site?

Yes. RapidDev has built 600+ apps including content platforms and subscription businesses. They can add custom content types, community features, referral programs, and integrations with course platforms. Book a free consultation at rapidevelopers.com.

Can I offer a free trial?

Yes. When creating the Stripe Checkout Session, add trial_period_days: 14 (or your desired trial length) to the subscription_data object. The trial starts immediately, the member gets full access, and billing begins after the trial ends. Handle customer.subscription.trial_will_end events to send reminder emails.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.