Skip to main content
RapidDev - Software Development Agency

How to Build a Subscription System with Replit

Build a SaaS subscription system with Replit in 1-2 hours. You'll create an Express API with Stripe Checkout (subscription mode), a feature-gating middleware, a Stripe Customer Portal for self-service billing, and a PostgreSQL database (Drizzle ORM) for plan and subscription data. Use the Replit /stripe command for instant Stripe provisioning. Deploy on Autoscale.

What you'll build

  • Pricing page with free/pro/enterprise tier cards and feature comparison
  • Stripe Checkout flow in subscription mode with optional 14-day trial
  • Stripe Customer Portal for subscribers to upgrade, downgrade, or cancel
  • Feature-gating middleware that checks subscription status on protected routes
  • Subscription lifecycle webhook handler for created, updated, deleted, and payment_failed events
  • Current plan display and subscription status banner (trialing, past_due) in the UI
  • Idempotent webhook processing using a webhook_events deduplication table
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate13 min read1-2 hoursReplit FreeApril 2026RapidDev Engineering Team
TL;DR

Build a SaaS subscription system with Replit in 1-2 hours. You'll create an Express API with Stripe Checkout (subscription mode), a feature-gating middleware, a Stripe Customer Portal for self-service billing, and a PostgreSQL database (Drizzle ORM) for plan and subscription data. Use the Replit /stripe command for instant Stripe provisioning. Deploy on Autoscale.

What you're building

A subscription system is the revenue engine of any SaaS product. Without it, founders either block all features (losing users) or give everything away for free (losing revenue). The right pattern — free tier with limited features, paid tiers unlocking progressively more — requires recurring billing, a middleware layer that checks plan status on every protected request, and self-service billing management.

Replit's `/stripe` command auto-provisions a Stripe sandbox, creates the checkout and webhook scaffolding, and injects API keys into your Replit Secrets. Agent generates the Express API and React frontend from one prompt. The hardest part — getting Stripe Checkout and webhooks working together correctly — is handled by the platform.

The architecture uses a `plans` table with a `features` jsonb column to define what each tier includes, a `subscriptions` table synced by Stripe webhooks, and a `requireFeature(key)` middleware function that any route can use to gate access. When a subscriber upgrades mid-cycle, the `customer.subscription.updated` webhook immediately updates their plan in the database — no waiting for the next billing cycle.

Final result

A complete SaaS subscription system with pricing page, Stripe Checkout, Customer Portal, feature-gating middleware, subscription status UI, and a full webhook lifecycle handler — ready to drop into any Express application.

Tech stack

ReplitIDE & Hosting
ExpressBackend Framework
PostgreSQLDatabase
Drizzle ORMDatabase ORM
StripePayments
Replit AuthAuth
ReactFrontend

Prerequisites

  • A Replit account (Free tier is sufficient)
  • Basic understanding of what subscriptions and API routes do (no coding experience needed)
  • Stripe account for going live (the /stripe command provisions a sandbox for testing)
  • Defined feature list for each tier before you start (e.g. free: 3 projects, pro: unlimited)

Build steps

1

Provision Stripe and scaffold the project with Agent

From the Replit home screen, type `/stripe` to launch the Stripe integration. This auto-provisions a sandbox Stripe account and injects `STRIPE_SECRET_KEY` and `STRIPE_PUBLISHABLE_KEY` into Replit Secrets. Then use the Agent prompt below to generate the full app structure including the Drizzle schema.

prompt.txt
1// After running /stripe, paste this into Replit Agent:
2// Build a SaaS subscription system with Express, PostgreSQL (Drizzle ORM), and Stripe.
3// Schema: plans (id serial PK, name text, description text, stripe_price_id text unique,
4// amount integer cents, interval text monthly/yearly, features jsonb array of {key, label, included bool},
5// display_order integer default 0, is_active bool default true),
6// subscriptions (id serial PK, user_id text unique, plan_id int references plans,
7// stripe_subscription_id text unique, stripe_customer_id text,
8// status text active/trialing/past_due/canceled/incomplete,
9// current_period_start timestamp, current_period_end timestamp,
10// cancel_at_period_end bool default false, trial_end timestamp, created_at),
11// subscription_events (id serial PK, subscription_id int references subscriptions,
12// event_type text, stripe_event_id text unique, metadata jsonb, created_at),
13// webhook_events (id serial PK, stripe_event_id text unique, event_type text, processed_at timestamp).
14// Routes: GET /api/plans, POST /api/subscribe (Stripe Checkout mode=subscription, 14-day trial),
15// POST /api/billing-portal (Stripe Customer Portal), GET /api/subscription,
16// GET /api/subscription/features, POST /api/webhooks/stripe.
17// Mount /api/webhooks/stripe BEFORE express.json() using express.raw().
18// Core middleware: requireFeature(key) — loads user subscription, checks plan features jsonb,
19// returns 403 if key not included: true.
20// React: pricing page with plan cards, feature comparison, current plan badge, upgrade button;
21// subscription status banner; billing portal link.
22// Bind server to 0.0.0.0.

Pro tip: Define your plans and features BEFORE running Agent. Write out: plan names, monthly prices, and which features each tier includes. This makes the seed data much easier to write.

Expected result: Agent creates the full project. The preview shows the pricing page with plan cards.

2

Create Stripe Price IDs and seed the plans table

In Stripe Dashboard (test mode), create a Product for each plan (e.g. 'Pro Plan'), then create a recurring monthly Price. Copy the price_XXXX IDs, then seed your plans table via Drizzle Studio. This is the link between your database and Stripe's billing engine.

server/seed.js
1// Seed script — run once to populate plans table
2// server/seed.js
3const { db } = require('./db');
4const { plans } = require('./schema');
5
6async function seed() {
7 await db.insert(plans).values([
8 {
9 name: 'Free',
10 description: 'Get started with the basics',
11 stripePriceId: 'price_free', // no Stripe price for free tier
12 amount: 0,
13 interval: 'monthly',
14 features: [
15 { key: 'projects', label: 'Up to 3 projects', included: true },
16 { key: 'api_access', label: 'API access', included: false },
17 { key: 'team_members', label: 'Team members', included: false },
18 ],
19 displayOrder: 0,
20 isActive: true,
21 },
22 {
23 name: 'Pro',
24 description: 'For growing teams',
25 stripePriceId: 'price_XXXX', // replace with your Stripe price ID
26 amount: 2900, // $29.00 in cents
27 interval: 'monthly',
28 features: [
29 { key: 'projects', label: 'Unlimited projects', included: true },
30 { key: 'api_access', label: 'API access', included: true },
31 { key: 'team_members', label: 'Up to 10 team members', included: true },
32 ],
33 displayOrder: 1,
34 isActive: true,
35 },
36 ]).onConflictDoNothing();
37
38 console.log('Plans seeded');
39 process.exit(0);
40}
41
42seed().catch(console.error);

Pro tip: Open Drizzle Studio from the Database tool to verify the seeded rows. You can also edit plan data directly in the studio without redeploying.

3

Build the requireFeature middleware

This is the core of the subscription system. The middleware loads the user's active subscription, reads the `features` array from their plan, and either allows the request or returns a 402 with an upgrade prompt. Every protected route simply wraps with `requireFeature('api_access')`.

server/middleware/requireFeature.js
1// server/middleware/requireFeature.js
2const { eq, and, inArray } = require('drizzle-orm');
3const { db } = require('../db');
4const { subscriptions, plans } = require('../schema');
5
6const ACTIVE_STATUSES = ['active', 'trialing'];
7
8function requireFeature(featureKey) {
9 return async (req, res, next) => {
10 if (!req.user) {
11 return res.status(401).json({ error: 'Login required' });
12 }
13
14 // Load subscription with plan data
15 const rows = await db
16 .select({
17 status: subscriptions.status,
18 features: plans.features,
19 planName: plans.name,
20 })
21 .from(subscriptions)
22 .innerJoin(plans, eq(subscriptions.planId, plans.id))
23 .where(
24 and(
25 eq(subscriptions.userId, req.user.id),
26 inArray(subscriptions.status, ACTIVE_STATUSES)
27 )
28 )
29 .limit(1);
30
31 if (rows.length === 0) {
32 return res.status(402).json({
33 error: 'Subscription required',
34 upgradeUrl: '/pricing',
35 });
36 }
37
38 const { features, planName } = rows[0];
39 const feature = features.find(f => f.key === featureKey);
40
41 if (!feature || !feature.included) {
42 return res.status(403).json({
43 error: `Feature '${featureKey}' requires a higher plan`,
44 currentPlan: planName,
45 upgradeUrl: '/pricing',
46 });
47 }
48
49 req.subscription = rows[0];
50 next();
51 };
52}
53
54module.exports = { requireFeature };
55
56// Usage example:
57// const { requireFeature } = require('../middleware/requireFeature');
58// app.get('/api/pro/data', requireFeature('api_access'), (req, res) => {
59// res.json({ data: 'premium content' });
60// });

Expected result: Calling a feature-gated route without an active subscription returns HTTP 402 with an upgradeUrl. With an active subscription and the feature included, the route proceeds normally.

4

Build the Stripe webhook handler

Webhooks keep your database in sync with Stripe's subscription state. The handler processes subscription created/updated/deleted events and payment failures. It MUST use synchronous `constructEvent` (not constructEventAsync) and be mounted with `express.raw()` BEFORE `express.json()`.

server/routes/webhook.js
1// server/routes/webhook.js
2const express = require('express');
3const stripe = require('../stripe');
4const { db } = require('../db');
5const { eq } = require('drizzle-orm');
6const { subscriptions, plans, subscriptionEvents, webhookEvents } = require('../schema');
7
8const router = express.Router();
9
10router.post('/', async (req, res) => {
11 const sig = req.headers['stripe-signature'];
12 let event;
13
14 try {
15 // SYNC — do NOT use constructEventAsync on Node.js/Replit
16 event = stripe.webhooks.constructEvent(
17 req.body, sig, process.env.STRIPE_WEBHOOK_SECRET
18 );
19 } catch (err) {
20 return res.status(400).send(`Webhook Error: ${err.message}`);
21 }
22
23 // Idempotency
24 const inserted = await db.insert(webhookEvents)
25 .values({ id: event.id, eventType: event.type })
26 .onConflictDoNothing()
27 .returning({ id: webhookEvents.id });
28
29 if (inserted.length === 0) return res.json({ received: true });
30
31 try {
32 const sub = event.data.object;
33 switch (event.type) {
34 case 'customer.subscription.created': {
35 const item = sub.items.data[0];
36 const [plan] = await db.select().from(plans)
37 .where(eq(plans.stripePriceId, item.price.id)).limit(1);
38
39 await db.insert(subscriptions).values({
40 userId: sub.metadata.user_id,
41 planId: plan.id,
42 stripeSubscriptionId: sub.id,
43 stripeCustomerId: sub.customer,
44 status: sub.status,
45 currentPeriodStart: new Date(sub.current_period_start * 1000),
46 currentPeriodEnd: new Date(sub.current_period_end * 1000),
47 cancelAtPeriodEnd: sub.cancel_at_period_end,
48 trialEnd: sub.trial_end ? new Date(sub.trial_end * 1000) : null,
49 }).onConflictDoNothing();
50 break;
51 }
52 case 'customer.subscription.updated': {
53 const item = sub.items.data[0];
54 const [plan] = await db.select().from(plans)
55 .where(eq(plans.stripePriceId, item.price.id)).limit(1);
56
57 await db.update(subscriptions)
58 .set({
59 planId: plan?.id,
60 status: sub.status,
61 currentPeriodEnd: new Date(sub.current_period_end * 1000),
62 cancelAtPeriodEnd: sub.cancel_at_period_end,
63 })
64 .where(eq(subscriptions.stripeSubscriptionId, sub.id));
65 break;
66 }
67 case 'customer.subscription.deleted':
68 await db.update(subscriptions)
69 .set({ status: 'canceled' })
70 .where(eq(subscriptions.stripeSubscriptionId, sub.id));
71 break;
72 case 'invoice.payment_failed':
73 await db.update(subscriptions)
74 .set({ status: 'past_due' })
75 .where(eq(subscriptions.stripeSubscriptionId, sub.subscription));
76 break;
77 }
78 return res.json({ received: true });
79 } catch (err) {
80 console.error('[webhook] error:', err);
81 return res.status(500).send('handler error');
82 }
83});
84
85module.exports = router;

Pro tip: After deploying, add your *.replit.app/api/webhooks/stripe URL in the Stripe Dashboard under Developers > Webhooks. Copy the signing secret and add it as STRIPE_WEBHOOK_SECRET in your Deployment Secrets (not Workspace Secrets).

5

Add the Stripe Customer Portal route and deploy

The Customer Portal lets subscribers update their payment method, switch plans, or cancel — without you building a billing settings UI. After deploying on Autoscale, you must configure the Customer Portal in Stripe Dashboard first (save the settings once), or you'll get a test-mode error on first use.

server/routes/portal.js
1// server/routes/portal.js
2const express = require('express');
3const stripe = require('../stripe');
4const { getBaseUrl } = require('../lib/baseUrl');
5const { db } = require('../db');
6const { eq } = require('drizzle-orm');
7const { subscriptions } = require('../schema');
8
9const router = express.Router();
10
11router.post('/api/billing-portal', express.json(), async (req, res) => {
12 const userId = req.user.id;
13 const baseUrl = getBaseUrl();
14
15 const [sub] = await db.select()
16 .from(subscriptions)
17 .where(eq(subscriptions.userId, userId))
18 .limit(1);
19
20 if (!sub) {
21 return res.status(404).json({ error: 'No subscription found' });
22 }
23
24 // NOTE: First use in test mode requires saving portal settings at
25 // https://dashboard.stripe.com/test/settings/billing/portal
26 const portalSession = await stripe.billingPortal.sessions.create({
27 customer: sub.stripeCustomerId,
28 return_url: `${baseUrl}/account`,
29 });
30
31 res.json({ url: portalSession.url });
32});
33
34module.exports = router;

Expected result: Clicking 'Manage Billing' in the React frontend redirects to a Stripe-hosted portal where subscribers can update payment info, switch plans, or cancel.

Complete code

server/middleware/requireFeature.js
1const { eq, and, inArray } = require('drizzle-orm');
2const { db } = require('../db');
3const { subscriptions, plans } = require('../schema');
4
5const ACTIVE_STATUSES = ['active', 'trialing'];
6
7// Middleware factory — gates any route behind a specific feature key
8// Usage: app.get('/api/feature', requireFeature('api_access'), handler)
9function requireFeature(featureKey) {
10 return async (req, res, next) => {
11 if (!req.user) {
12 return res.status(401).json({ error: 'Login required' });
13 }
14
15 try {
16 const rows = await db
17 .select({
18 subId: subscriptions.id,
19 status: subscriptions.status,
20 planId: subscriptions.planId,
21 currentPeriodEnd: subscriptions.currentPeriodEnd,
22 features: plans.features,
23 planName: plans.name,
24 displayOrder: plans.displayOrder,
25 })
26 .from(subscriptions)
27 .innerJoin(plans, eq(subscriptions.planId, plans.id))
28 .where(
29 and(
30 eq(subscriptions.userId, req.user.id),
31 inArray(subscriptions.status, ACTIVE_STATUSES)
32 )
33 )
34 .limit(1);
35
36 // No active subscription — prompt to subscribe
37 if (rows.length === 0) {
38 return res.status(402).json({
39 error: 'Subscription required to access this feature',
40 upgradeUrl: '/pricing',
41 });
42 }
43
44 const row = rows[0];
45 const feature = row.features.find(f => f.key === featureKey);
46
47 // Feature not in plan
48 if (!feature || !feature.included) {
49 return res.status(403).json({
50 error: `'${featureKey}' is not available on the ${row.planName} plan`,
51 currentPlan: row.planName,
52 upgradeUrl: '/pricing',
53 });
54 }
55
56 // Attach subscription context for use in the route handler
57 req.subscription = row;
58 next();
59 } catch (err) {
60 console.error('[requireFeature] DB error:', err.message);

Customization ideas

Annual pricing with discount

Add a `yearly` Stripe Price for each plan at a discounted rate (e.g., 20% off). Add a monthly/annual toggle to the pricing page. Pass the selected price ID to the subscribe route. The webhook handler resolves the plan by matching the price ID regardless of interval.

Usage-based billing with seat counts

Add a `seats` column to subscriptions. Use `stripe.subscriptions.update({ items: [{ id, quantity: newSeats }] })` when the team size changes. Stripe prorates the charge automatically. Add a seats management UI in the account dashboard.

Promo codes and discounts

Add `allow_promotion_codes: true` to the Checkout Session creation. Stripe shows a promo code input field on the Checkout page. Create codes in the Stripe Dashboard under Promotions. No code changes needed beyond the one flag.

Trial end email reminder

Handle the `customer.subscription.trial_will_end` webhook event (fires 3 days before trial ends). Send an email via SendGrid reminding the user their trial is ending and prompting them to add a payment method. Store the SendGrid API key in Replit Secrets.

Common pitfalls

Pitfall: Webhook signature verification fails with 'No signatures found'

How to avoid: Mount the webhook route BEFORE app.use(express.json()) in server/index.js and use express.raw({ type: 'application/json' }) only on that route.

Pitfall: Plan upgrade doesn't take effect immediately

How to avoid: Update the local plan_id when the `customer.subscription.updated` webhook fires — this happens immediately when Stripe processes the plan change, not at the next billing date.

Pitfall: Free tier users can't access any routes after adding the middleware

How to avoid: On first login, auto-insert a subscription row pointing to the free plan with status='active'. This gives free tier users a subscription record that the middleware can check.

Best practices

  • Store STRIPE_SECRET_KEY, STRIPE_PUBLISHABLE_KEY, and STRIPE_WEBHOOK_SECRET in Replit Secrets (lock icon) — add them again separately in Deployment Secrets
  • Mount the webhook route BEFORE app.use(express.json()) using express.raw({ type: 'application/json' }) — this is the most common Stripe integration mistake
  • Use the webhook_events table for idempotency — insert with ON CONFLICT DO NOTHING and skip processing if no rows were inserted
  • Seed default plan rows on first deployment with the Stripe price IDs — use Drizzle Studio to verify the data looks correct
  • Handle plan changes immediately in customer.subscription.updated, not invoice.paid — customers expect instant access after upgrading
  • Use the /stripe command in Replit for initial setup — it provisions the sandbox, creates routes, and injects Secrets automatically
  • Test with Stripe card 4242 4242 4242 4242 before going live, and use the Stripe Events tab to verify all webhook events are being received

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building a SaaS subscription system using Express.js, PostgreSQL with Drizzle ORM, and Stripe on Replit. My plans table has a features jsonb column like [{key: 'api_access', label: 'API Access', included: true}]. Help me write a requireFeature(key) middleware function that queries the user's active subscription, joins to the plans table, checks if the feature key has included: true in the jsonb array, and returns 403 with an upgradeUrl if not. Also explain how to handle the case where a free tier user has no subscription row at all.

Build Prompt

Add a subscription status banner to my React frontend for this SaaS subscription system. The banner should: show nothing when status is 'active', show a yellow warning 'Your trial ends in X days' when status is 'trialing' (calculate days from trial_end), show a red alert 'Payment failed — update your payment method' with a link to the billing portal when status is 'past_due', and show a gray notice 'Your subscription ends on [date]' when cancel_at_period_end is true. Fetch the current subscription status from GET /api/subscription on app load and store it in React context.

Frequently asked questions

Do I need a paid Stripe account to build this?

No. The Replit /stripe command provisions a free test mode Stripe sandbox. Use test card 4242 4242 4242 4242 to simulate payments. You only need a live Stripe account with KYC verification when you're ready to charge real customers.

How do I handle users on the free plan with no Stripe subscription?

On first login, auto-insert a subscriptions row pointing to your free plan with status='active' and no Stripe IDs. This gives free tier users a subscription record that the requireFeature middleware can check. Free features pass through; paid features return 403 with an upgradeUrl.

What's the difference between Workspace Secrets and Deployment Secrets?

Workspace Secrets (lock icon in sidebar) are used when running your app in the Replit editor. Deployment Secrets are configured in the Publish pane and used by your live deployed app. They are completely separate — you must add Stripe keys in both places.

Should I use Autoscale or Reserved VM for this app?

Autoscale works for most SaaS subscription systems. Traffic is moderate and predictable. Stripe retries failed webhooks for up to 3 days, so occasional cold starts don't cause lost events. Reserve VM only if you're processing high volumes (hundreds of payments per day).

How do plan upgrades work mid-billing cycle?

When a subscriber upgrades via the Stripe Customer Portal, Stripe prorates the charge and fires a `customer.subscription.updated` webhook. Your webhook handler updates the local plan_id and status immediately, giving the subscriber instant access to the higher tier's features.

How do I prevent someone from calling protected routes after their subscription expires?

The requireFeature middleware checks the subscription status against an allowed list ['active', 'trialing']. If the status is 'past_due', 'canceled', or 'incomplete', the middleware returns 402. The status is updated in real time by webhook handlers.

Can I add a free trial without requiring a credit card?

Yes, but with a trade-off. Set `payment_method_collection: 'if_required'` on the Checkout Session and add `trial_period_days`. Users can trial without a card, but when the trial ends they're moved to 'paused' status (not 'active') until they add a payment method. Handle the `customer.subscription.paused` webhook.

Can RapidDev help me build a custom subscription system?

Yes. RapidDev has built 600+ apps including SaaS platforms with complex subscription logic, usage-based billing, and multi-tenant architectures. Book a free consultation at rapidevelopers.com.

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.