# How to Build a Membership Site with Replit

- Tool: How to Build with Replit
- Difficulty: Intermediate
- Compatibility: Replit Free
- Last updated: April 2026

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

## Before you start

- 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

## Step-by-step guide

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

```
// Step 1: Type this into Replit Agent:
// Build a membership site with Express and PostgreSQL using Drizzle ORM.
// Tables:
// - membership_tiers: id serial pk, name text not null, description text,
//   price integer not null (cents per month, 0 for free), stripe_price_id text unique,
//   features jsonb, max_members integer, display_order integer default 0,
//   is_active boolean default true
// - members: id serial pk, user_id text unique not null, email text not null, name text,
//   tier_id integer FK membership_tiers not null, stripe_customer_id text,
//   stripe_subscription_id text, status text default 'active'
//   (enum: active/expired/cancelled/paused), joined_at timestamp default now(),
//   expires_at timestamp
// - protected_content: id serial pk, title text not null, body text not null,
//   min_tier_id integer FK membership_tiers not null,
//   content_type text default 'article' (enum: article/video/download/course),
//   file_url text, published_at timestamp, created_at timestamp default now()
// - member_content_access: id serial pk, member_id integer FK members,
//   content_id integer FK protected_content, accessed_at timestamp default now()
// - webhook_events: id serial pk, stripe_event_id text unique, event_type text,
//   processed_at timestamp default now()
// Routes: GET /api/tiers, POST /api/members/join, GET /api/members/me,
// POST /api/billing-portal, GET /api/content, GET /api/content/:id,
// POST /api/admin/content, GET /api/admin/members, POST /api/webhooks/stripe.
// Replit Auth. Bind server to 0.0.0.0.

// Step 2: After Agent creates the project, type in Replit chat:
// /stripe
// This provisions Stripe test sandbox, adds STRIPE_SECRET_KEY and
// 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 — run once to create membership tiers
const { db } = require('../server/db');
const { membershipTiers } = require('./shared/schema');

async function seedTiers() {
  // Create tiers in order (display_order determines access hierarchy)
  await db.insert(membershipTiers).values([
    {
      name: 'Free',
      description: 'Access to free content',
      price: 0,
      stripePriceId: null, // No Stripe price for free tier
      features: JSON.stringify(['Access to free articles', 'Weekly newsletter']),
      displayOrder: 0,
      isActive: true,
    },
    {
      name: 'Silver',
      description: 'Access to Silver and Free content',
      price: 900, // $9/month in cents
      // Get this from Stripe Dashboard → Products → Create a product → Add price (recurring)
      stripePriceId: process.env.STRIPE_SILVER_PRICE_ID,
      features: JSON.stringify(['Everything in Free', 'Premium articles', 'Monthly webinars']),
      displayOrder: 1,
      isActive: true,
    },
    {
      name: 'Gold',
      description: 'Access to all content',
      price: 2900, // $29/month in cents
      stripePriceId: process.env.STRIPE_GOLD_PRICE_ID,
      features: JSON.stringify(['Everything in Silver', 'Video courses', 'Direct Q&A access', '1-on-1 calls']),
      displayOrder: 2,
      isActive: true,
    },
  ]).onConflictDoNothing();

  console.log('Tiers seeded. Add STRIPE_SILVER_PRICE_ID and STRIPE_GOLD_PRICE_ID to Replit Secrets.');
  process.exit(0);
}

seedTiers().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.

```
const { db } = require('../db');
const { members, membershipTiers } = require('../../shared/schema');
const { eq } = require('drizzle-orm');

// Middleware: checks member tier against required minimum tier
exports.requireTier = (minTierOrder) => async (req, res, next) => {
  const userId = req.user?.id;
  if (!userId) {
    return res.status(401).json({ error: 'Login required', loginUrl: '/login' });
  }

  const result = await db
    .select({
      memberId: members.id,
      tierOrder: membershipTiers.displayOrder,
      tierName: membershipTiers.name,
      status: members.status,
    })
    .from(members)
    .innerJoin(membershipTiers, eq(members.tierId, membershipTiers.id))
    .where(eq(members.userId, userId));

  if (!result[0]) {
    return res.status(403).json({
      error: 'No active membership',
      upgradeUrl: '/pricing',
    });
  }

  const member = result[0];

  if (member.status !== 'active') {
    return res.status(403).json({
      error: 'Membership expired or cancelled',
      upgradeUrl: '/billing',
    });
  }

  if (member.tierOrder < minTierOrder) {
    return res.status(403).json({
      error: `This content requires a higher membership tier`,
      currentTier: member.tierName,
      upgradeUrl: '/pricing',
    });
  }

  // Attach member info to request for use in route handlers
  req.member = member;
  next();
};

// GET /api/content/:id — protected content with access logging
exports.getContent = async (req, res) => {
  const { protectedContent, memberContentAccess } = require('../../shared/schema');
  const [content] = await db.select().from(protectedContent)
    .where(eq(protectedContent.id, parseInt(req.params.id)));

  if (!content) return res.status(404).json({ error: 'Content not found' });

  // Log access
  await db.insert(memberContentAccess).values({
    memberId: req.member.memberId,
    contentId: content.id,
  }).onConflictDoNothing();

  res.json(content);
};
```

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

```
const Stripe = require('stripe');
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const { members, membershipTiers } = require('../../shared/schema');
const { eq } = require('drizzle-orm');

// POST /api/members/join — start subscription checkout
router.post('/members/join', async (req, res) => {
  const userId = req.user?.id;
  if (!userId) return res.status(401).json({ error: 'Login required' });

  const { tierId } = req.body;
  const [tier] = await db.select().from(membershipTiers).where(eq(membershipTiers.id, tierId));
  if (!tier) return res.status(404).json({ error: 'Tier not found' });

  // Free tier: create member directly without Stripe
  if (tier.price === 0) {
    await db.insert(members).values({
      userId,
      email: req.user.email || '',
      tierId: tier.id,
      status: 'active',
    }).onConflictDoNothing();
    return res.json({ redirect: '/dashboard' });
  }

  // Paid tier: create Stripe Checkout Session for subscription
  const session = await stripe.checkout.sessions.create({
    payment_method_types: ['card'],
    mode: 'subscription',
    line_items: [{ price: tier.stripePriceId, quantity: 1 }],
    success_url: `${process.env.REPLIT_DEPLOYMENT_URL}/dashboard?welcome=1`,
    cancel_url: `${process.env.REPLIT_DEPLOYMENT_URL}/pricing`,
    metadata: { userId, tierId: tier.id.toString() },
  });

  res.json({ checkoutUrl: session.url });
});

// POST /api/billing-portal — Stripe Customer Portal
router.post('/billing-portal', async (req, res) => {
  const userId = req.user?.id;
  const [member] = await db.select().from(members).where(eq(members.userId, userId));
  if (!member?.stripeCustomerId) {
    return res.status(400).json({ error: 'No billing information found' });
  }

  const portalSession = await stripe.billingPortal.sessions.create({
    customer: member.stripeCustomerId,
    return_url: `${process.env.REPLIT_DEPLOYMENT_URL}/dashboard`,
  });

  res.json({ url: portalSession.url });
});
```

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

```
// POST /api/webhooks/stripe — handle subscription lifecycle
// Register this route BEFORE express.json() in server/index.js
router.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
  const sig = req.headers['stripe-signature'];
  let event;

  try {
    event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  const existing = await db.select().from(webhookEvents)
    .where(eq(webhookEvents.stripeEventId, event.id));
  if (existing.length > 0) return res.json({ received: true });
  await db.insert(webhookEvents).values({ stripeEventId: event.id, eventType: event.type });

  if (event.type === 'checkout.session.completed') {
    const session = event.data.object;
    if (session.mode !== 'subscription') return res.json({ received: true });

    const { userId, tierId } = session.metadata;
    await db.insert(members).values({
      userId,
      email: session.customer_details?.email || '',
      tierId: parseInt(tierId),
      stripeCustomerId: session.customer,
      stripeSubscriptionId: session.subscription,
      status: 'active',
    }).onConflictDoUpdate({
      target: members.userId,
      set: { tierId: parseInt(tierId), status: 'active', stripeSubscriptionId: session.subscription },
    });
  }

  if (event.type === 'customer.subscription.deleted') {
    const subscription = event.data.object;
    await db.update(members)
      .set({ status: 'expired' })
      .where(eq(members.stripeSubscriptionId, subscription.id));
  }

  res.json({ received: true });
});
```

> 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 example

File: `server/middleware/membership.js`

```javascript
const { db } = require('../db');
const { members, membershipTiers, memberContentAccess } = require('../../shared/schema');
const { eq } = require('drizzle-orm');

// Middleware factory: requireTier(minOrder) checks if member's tier is >= minOrder
exports.requireTier = (minTierOrder) => async (req, res, next) => {
  const userId = req.user?.id;
  if (!userId) return res.status(401).json({ error: 'Login required', loginUrl: '/login' });

  const result = await db
    .select({
      memberId: members.id,
      tierOrder: membershipTiers.displayOrder,
      tierName: membershipTiers.name,
      status: members.status,
    })
    .from(members)
    .innerJoin(membershipTiers, eq(members.tierId, membershipTiers.id))
    .where(eq(members.userId, userId));

  if (!result[0]) return res.status(403).json({ error: 'No active membership', upgradeUrl: '/pricing' });

  const member = result[0];
  if (member.status !== 'active') return res.status(403).json({ error: 'Membership expired', upgradeUrl: '/billing' });
  if (member.tierOrder < minTierOrder) {
    return res.status(403).json({ error: 'Upgrade required', currentTier: member.tierName, upgradeUrl: '/pricing' });
  }

  req.member = member;
  next();
};

// List content with lock/unlock metadata for current member
exports.listContent = async (req, res) => {
  const userId = req.user?.id;
  const memberResult = userId ? await db
    .select({ tierOrder: membershipTiers.displayOrder })
    .from(members)
    .innerJoin(membershipTiers, eq(members.tierId, membershipTiers.id))
    .where(eq(members.userId, userId)) : [];

  const memberTierOrder = memberResult[0]?.tierOrder ?? -1;

  const { protectedContent } = require('../../shared/schema');
  const contentList = await db
    .select({
      id: protectedContent.id,
      title: protectedContent.title,
      contentType: protectedContent.contentType,
      publishedAt: protectedContent.publishedAt,
      minTierOrder: membershipTiers.displayOrder,
      minTierName: membershipTiers.name,
    })
    .from(protectedContent)
    .innerJoin(membershipTiers, eq(protectedContent.minTierId, membershipTiers.id))
    .where(eq(protectedContent.publishedAt, protectedContent.publishedAt)) // active content only
    .orderBy(protectedContent.publishedAt);

  const withAccess = contentList.map(c => ({
    ...c,
    isLocked: memberTierOrder < c.minTierOrder,
  }));

  res.json(withAccess);
};
```

## Common mistakes

- **Using points_balance or a boolean for tier access instead of display_order** — A boolean 'is_gold' field breaks when you add a new tier between Silver and Gold. A tier_id check requires a separate comparison for every tier pair. Fix: 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.
- **Registering the webhook route after express.json()** — Stripe's constructEvent needs the raw request body Buffer. express.json() replaces the body with a parsed object, breaking signature verification. Fix: 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.
- **Creating member records before the Stripe payment confirms** — If you create the member record when the checkout session is created (not when payment completes), cancelled payments still result in active members with unpaid access. Fix: 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.
- **Forgetting to handle subscription downgrades in the webhook** — When a member downgrades from Gold to Silver, the customer.subscription.updated event fires with a new price_id. Without handling this, they retain Gold access on a Silver subscription. Fix: 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.

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

---

Source: https://www.rapidevelopers.com/how-to-build-replit/membership-site
© RapidDev — https://www.rapidevelopers.com/how-to-build-replit/membership-site
