Add Stripe Billing to Your SaaS (Next.js + RaidFrame)
Step-by-step guide to adding Stripe subscriptions, checkout, webhooks, and route protection to a Next.js SaaS app deployed on RaidFrame.
RaidFrame Team
March 9, 2026 · 6 min read
TL;DR — Install the Stripe SDK, create a checkout session API route, handle webhooks for payment events, and protect routes based on subscription status. Full loop from free trial to paid plan — with TypeScript code you can drop into a Next.js app on RaidFrame.
Note: This example uses Prisma. If you prefer Drizzle, the Stripe integration is identical — only the database queries change. See our recommended SaaS stack for why we suggest Drizzle for new projects.
Prerequisites
You need a Next.js app deployed on RaidFrame with Postgres. If you don't have one, follow Deploy Next.js + Postgres for Free first. You also need a Stripe account — no verification needed for test mode.
Step 1: Install the Stripe SDK and set env vars
npm install stripeAdd keys to .env locally, and on RaidFrame set them as encrypted environment variables:
rf env set STRIPE_SECRET_KEY sk_test_xxx
rf env set STRIPE_WEBHOOK_SECRET whsec_xxx
rf env set NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY pk_test_xxxNever commit API keys to your repo. RaidFrame encrypts secrets at rest and injects them at runtime.
Step 2: Create products and prices in Stripe Dashboard
Go to Stripe Dashboard > Products and create your plans — e.g. Pro at $29/mo and Team at $79/mo. Copy the Price IDs (they start with price_). For metered billing, create a usage-based price. For per-seat pricing, set price per unit and pass quantity at checkout.
Step 3: Checkout session API route
Create src/app/api/stripe/checkout/route.ts:
import Stripe from "stripe";
import { NextResponse } from "next/server";
import { PrismaClient } from "@prisma/client";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const prisma = new PrismaClient();
export async function POST(request: Request) {
const { userId, priceId } = await request.json();
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) return NextResponse.json({ error: "User not found" }, { status: 404 });
let customerId = user.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({ email: user.email });
customerId = customer.id;
await prisma.user.update({ where: { id: userId }, data: { stripeCustomerId: customerId } });
}
const session = await stripe.checkout.sessions.create({
customer: customerId, mode: "subscription",
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/billing?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/billing?canceled=true`,
subscription_data: { trial_period_days: 14 },
});
return NextResponse.json({ url: session.url });
}Redirect the user to session.url. Stripe handles the entire payment flow.
Try RaidFrame free
Deploy your first app in 60 seconds. No credit card required.
Step 4: Webhook handler for payment events
Webhooks are how Stripe tells your app a payment succeeded, a subscription renewed, or a customer canceled. Never trust the client — always verify state server-side.
Create src/app/api/stripe/webhook/route.ts:
import Stripe from "stripe";
import { NextResponse } from "next/server";
import { PrismaClient } from "@prisma/client";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const prisma = new PrismaClient();
export async function POST(request: Request) {
const body = await request.text();
const signature = request.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
await prisma.user.update({
where: { stripeCustomerId: session.customer as string },
data: { subscriptionId: session.subscription as string, subscriptionStatus: "active" },
});
break;
}
case "invoice.paid": {
const invoice = event.data.object as Stripe.Invoice;
await prisma.user.update({ where: { stripeCustomerId: invoice.customer as string }, data: { subscriptionStatus: "active" } });
break;
}
case "customer.subscription.deleted": {
const sub = event.data.object as Stripe.Subscription;
await prisma.user.update({ where: { stripeCustomerId: sub.customer as string }, data: { subscriptionStatus: "canceled" } });
break;
}
}
return NextResponse.json({ received: true });
}Webhook signature verification prevents spoofed requests. The stripe-signature header contains a timestamp and HMAC — constructEvent validates both.
Step 5: Subscription management (portal, cancellation)
Don't build your own cancellation UI. Stripe's Customer Portal handles upgrades, downgrades, payment method updates, and cancellations. Create a /api/stripe/portal route that calls stripe.billingPortal.sessions.create() with the customer ID and a return_url. Enable it in Stripe Dashboard > Settings > Billing > Customer Portal.
Step 6: Protect routes based on subscription status
// src/lib/require-subscription.ts
import { PrismaClient } from "@prisma/client";
import { redirect } from "next/navigation";
const prisma = new PrismaClient();
export async function requireSubscription(userId: string) {
const user = await prisma.user.findUnique({ where: { id: userId }, select: { subscriptionStatus: true } });
if (user?.subscriptionStatus !== "active") redirect("/billing?upgrade=true");
}Call await requireSubscription(session.userId) at the top of any Server Component or API route that requires a paid plan.
Common billing patterns
Free trial to paid: Set trial_period_days in the checkout session. Stripe collects payment info upfront but charges after the trial. Handle customer.subscription.trial_will_end to send reminders.
Metered billing: Create a usage-based price, report usage with stripe.subscriptionItems.createUsageRecord(). Good for API calls or storage.
Per-seat pricing: Pass quantity in the line item. Update with stripe.subscriptions.update() when seats change.
Testing with Stripe CLI
stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhookThe CLI outputs a whsec_ signing secret for local testing. Use test cards: 4242 4242 4242 4242 for success, 4000 0000 0000 9995 for declined.
Deploy the webhook endpoint on RaidFrame
Your webhook needs a public URL. On RaidFrame, your app is already at your-app.raidframe.app. Go to Stripe Dashboard > Developers > Webhooks, add https://your-app.raidframe.app/api/stripe/webhook, select your events, then copy the signing secret:
rf env set STRIPE_WEBHOOK_SECRET whsec_live_xxx
rf deploySSL and a stable URL come out of the box. No tunnel, no extra config.
FAQ
Do I need Stripe.js on the frontend?
Not for this approach. Stripe Checkout is a hosted page — you redirect users to Stripe's URL. For an embedded form, use @stripe/react-stripe-js.
How do I handle failed payments?
Listen for invoice.payment_failed. Set the user's status to past_due and prompt them to update payment via the Customer Portal.
Can I offer annual billing?
Yes. Create a second price on the same product with recurring.interval: "year". Pass the correct priceId based on the user's selection.
How much does Stripe cost?
2.9% + $0.30 per transaction, no monthly fees. For a $29/mo subscription, that's ~$1.14. Volume discounts past $80K/year.
What about tax collection?
Add automatic_tax: { enabled: true } to your checkout session. Stripe Tax handles sales tax, VAT, and GST automatically.
How do I test webhooks before going live?
Use Stripe test mode — events fire against your live endpoint with test data. Monitor delivery under Developers > Webhooks in the dashboard.
Is it safe to expose a webhook endpoint publicly?
Yes, if you verify the stripe-signature header. The signature check in Step 4 ensures only Stripe can send valid events.
Related reading
Ship faster with RaidFrame
Auto-scaling compute, managed databases, global CDN, and zero-config CI/CD. Free tier included.