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
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
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.
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.
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.
1// Seed script — run once to populate plans table2// server/seed.js3const { db } = require('./db');4const { plans } = require('./schema');56async 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 tier12 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 ID26 amount: 2900, // $29.00 in cents27 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();3738 console.log('Plans seeded');39 process.exit(0);40}4142seed().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.
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')`.
1// server/middleware/requireFeature.js2const { eq, and, inArray } = require('drizzle-orm');3const { db } = require('../db');4const { subscriptions, plans } = require('../schema');56const ACTIVE_STATUSES = ['active', 'trialing'];78function requireFeature(featureKey) {9 return async (req, res, next) => {10 if (!req.user) {11 return res.status(401).json({ error: 'Login required' });12 }1314 // Load subscription with plan data15 const rows = await db16 .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);3031 if (rows.length === 0) {32 return res.status(402).json({33 error: 'Subscription required',34 upgradeUrl: '/pricing',35 });36 }3738 const { features, planName } = rows[0];39 const feature = features.find(f => f.key === featureKey);4041 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 }4849 req.subscription = rows[0];50 next();51 };52}5354module.exports = { requireFeature };5556// 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.
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()`.
1// server/routes/webhook.js2const express = require('express');3const stripe = require('../stripe');4const { db } = require('../db');5const { eq } = require('drizzle-orm');6const { subscriptions, plans, subscriptionEvents, webhookEvents } = require('../schema');78const router = express.Router();910router.post('/', async (req, res) => {11 const sig = req.headers['stripe-signature'];12 let event;1314 try {15 // SYNC — do NOT use constructEventAsync on Node.js/Replit16 event = stripe.webhooks.constructEvent(17 req.body, sig, process.env.STRIPE_WEBHOOK_SECRET18 );19 } catch (err) {20 return res.status(400).send(`Webhook Error: ${err.message}`);21 }2223 // Idempotency24 const inserted = await db.insert(webhookEvents)25 .values({ id: event.id, eventType: event.type })26 .onConflictDoNothing()27 .returning({ id: webhookEvents.id });2829 if (inserted.length === 0) return res.json({ received: true });3031 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);3839 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);5657 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});8485module.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).
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.
1// server/routes/portal.js2const 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');89const router = express.Router();1011router.post('/api/billing-portal', express.json(), async (req, res) => {12 const userId = req.user.id;13 const baseUrl = getBaseUrl();1415 const [sub] = await db.select()16 .from(subscriptions)17 .where(eq(subscriptions.userId, userId))18 .limit(1);1920 if (!sub) {21 return res.status(404).json({ error: 'No subscription found' });22 }2324 // NOTE: First use in test mode requires saving portal settings at25 // https://dashboard.stripe.com/test/settings/billing/portal26 const portalSession = await stripe.billingPortal.sessions.create({27 customer: sub.stripeCustomerId,28 return_url: `${baseUrl}/account`,29 });3031 res.json({ url: portalSession.url });32});3334module.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
1const { eq, and, inArray } = require('drizzle-orm');2const { db } = require('../db');3const { subscriptions, plans } = require('../schema');45const ACTIVE_STATUSES = ['active', 'trialing'];67// Middleware factory — gates any route behind a specific feature key8// 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 }1415 try {16 const rows = await db17 .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);3536 // No active subscription — prompt to subscribe37 if (rows.length === 0) {38 return res.status(402).json({39 error: 'Subscription required to access this feature',40 upgradeUrl: '/pricing',41 });42 }4344 const row = rows[0];45 const feature = row.features.find(f => f.key === featureKey);4647 // Feature not in plan48 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 }5556 // Attach subscription context for use in the route handler57 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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation