GuidesStripebillingSaaS

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.

R

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 stripe

Add 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_xxx

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

Start free

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/webhook

The 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 deploy

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

StripebillingSaaSNext.jssubscriptionswebhooks

Ship faster with RaidFrame

Auto-scaling compute, managed databases, global CDN, and zero-config CI/CD. Free tier included.