Next.js + Prisma + Stripe Tutorial
Next.js + Prisma + Stripe Tutorial (2026): Build a Production SaaS in One Sitting
Live demo: saas-demo.devkitmarket.com GitHub repo: github.com/devkit-market/nextjs-prisma-stripe-2026
5-Minute Overview — The Workflow
- Project Setup: Bootstrap Next.js 15 with TypeScript, Tailwind, and Prisma 6.
- Database Schema: Define User, Subscription, and Payment models in Prisma.
- Singletons: Create typed Prisma and Stripe clients for the whole app.
- Checkout Action: Build a Server Action that creates a Stripe session.
- Embedded Checkout UI: Mount Stripe inside your own page (no redirects).
- Webhook Handler: Verify events, write to Prisma, and handle idempotency.
- Customer Portal: Let users manage their subscription with one route.
Step 1 — Project Setup & Dependencies
npx create-next-app@latest saas-tutorial --typescript --tailwind --app
cd saas-tutorial
# Database + ORM
npm install prisma @prisma/client
npm install --save-dev tsx
# Stripe
npm install stripe @stripe/stripe-js @stripe/react-stripe-js
# Initialize Prisma
npx prisma init --datasource-provider postgresql
.env.localNEXT_PUBLIC_# .env.local
DATABASE_URL="postgresql://user:pass@localhost:5432/saas"
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_BASE_URL=http://localhost:3000
DATABASE_URLStep 2 — Define Your Prisma Schema
prisma/schema.prismaUserSubscriptionWebhookEventgenerator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String?
stripeCustomerId String? @unique
subscription Subscription?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Subscription {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
stripeSubscriptionId String @unique
stripePriceId String
status String // active, past_due, canceled, trialing
currentPeriodEnd DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model WebhookEvent {
id String @id @default(cuid())
stripeId String @unique
type String
processedAt DateTime @default(now())
}
npx prisma migrate dev --name init
npx prisma generate
@uniquestripeIdWebhookEventStep 3 — Singleton Clients (Critical)
lib/prisma.tsimport { PrismaClient } from "@prisma/client";
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma ||
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["query", "error"] : ["error"],
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
lib/stripe.tsimport Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2025-09-30.clover", // Pin the version — never auto-upgrade
typescript: true,
});
Step 4 — The Checkout Server Action
app/actions/checkout.ts"use server";
import { stripe } from "@/lib/stripe";
import { prisma } from "@/lib/prisma";
import { headers } from "next/headers";
export async function createCheckoutSession(priceId: string, userId: string) {
// 1. Always look up price on the server — never trust the client
const price = await stripe.prices.retrieve(priceId);
if (!price.active) throw new Error("Price is not active");
// 2. Get or create a Stripe customer linked to your DB user
let user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new Error("User not found");
if (!user.stripeCustomerId) {
const customer = await stripe.customers.create({
email: user.email,
name: user.name ?? undefined,
metadata: { userId: user.id },
});
user = await prisma.user.update({
where: { id: userId },
data: { stripeCustomerId: customer.id },
});
}
// 3. Create the Checkout Session
const origin = (await headers()).get("origin") || process.env.NEXT_PUBLIC_BASE_URL!;
const session = await stripe.checkout.sessions.create({
ui_mode: "embedded", // 2026 default — keeps users on your domain
customer: user.stripeCustomerId!,
line_items: [{ price: priceId, quantity: 1 }],
mode: price.recurring ? "subscription" : "payment",
return_url: `${origin}/return?session_id={CHECKOUT_SESSION_ID}`,
automatic_tax: { enabled: true },
allow_promotion_codes: true,
});
if (!session.client_secret) throw new Error("Could not create session");
return { clientSecret: session.client_secret };
}
Step 5 — The Embedded Checkout UI
app/checkout/page.tsx"use client";
import { useCallback } from "react";
import { loadStripe } from "@stripe/stripe-js";
import {
EmbeddedCheckoutProvider,
EmbeddedCheckout,
} from "@stripe/react-stripe-js";
import { createCheckoutSession } from "@/actions/checkout";
const stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!
);
export default function CheckoutPage({
searchParams,
}: {
searchParams: { priceId: string; userId: string };
}) {
const fetchClientSecret = useCallback(async () => {
const { clientSecret } = await createCheckoutSession(
searchParams.priceId,
searchParams.userId
);
return clientSecret;
}, [searchParams.priceId, searchParams.userId]);
return (
<div id="checkout" className="min-h-screen p-8">
<EmbeddedCheckoutProvider
stripe={stripePromise}
options={{ fetchClientSecret }}
>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
</div>
);
}
Step 6 — Webhook Handler with Prisma Sync (Critical)
app/api/webhook/stripe/route.tsimport { headers } from "next/headers";
import { NextResponse } from "next/server";
import type Stripe from "stripe";
import { stripe } from "@/lib/stripe";
import { prisma } from "@/lib/prisma";
export async function POST(req: Request) {
const body = await req.text(); // raw body — do NOT use req.json()
const signature = (await headers()).get("stripe-signature");
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature!,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err: any) {
return NextResponse.json(
{ error: \`Webhook Error: \${err.message}\` },
{ status: 400 }
);
}
// Idempotency check — Stripe may retry the same event
const alreadyProcessed = await prisma.webhookEvent.findUnique({
where: { stripeId: event.id },
});
if (alreadyProcessed) {
return NextResponse.json({ received: true, duplicate: true });
}
try {
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
if (session.mode === "subscription" && session.subscription) {
await syncSubscription(session.subscription as string);
}
break;
}
case "customer.subscription.updated":
case "customer.subscription.created":
await syncSubscription((event.data.object as Stripe.Subscription).id);
break;
case "customer.subscription.deleted": {
const sub = event.data.object as Stripe.Subscription;
await prisma.subscription.update({
where: { stripeSubscriptionId: sub.id },
data: { status: "canceled" },
});
break;
}
}
// Mark as processed only AFTER fulfillment succeeds
await prisma.webhookEvent.create({
data: { stripeId: event.id, type: event.type },
});
return NextResponse.json({ received: true });
} catch (err) {
console.error("Webhook handler failed:", err);
return NextResponse.json({ error: "Handler failed" }, { status: 500 });
}
}
async function syncSubscription(subscriptionId: string) {
const sub = await stripe.subscriptions.retrieve(subscriptionId);
const customerId = sub.customer as string;
const user = await prisma.user.findUnique({
where: { stripeCustomerId: customerId },
});
if (!user) throw new Error(\`No user for Stripe customer \${customerId}\`);
await prisma.subscription.upsert({
where: { stripeSubscriptionId: sub.id },
create: {
userId: user.id,
stripeSubscriptionId: sub.id,
stripePriceId: sub.items.data[0].price.id,
status: sub.status,
currentPeriodEnd: new Date(sub.current_period_end * 1000),
},
update: {
stripePriceId: sub.items.data[0].price.id,
status: sub.status,
currentPeriodEnd: new Date(sub.current_period_end * 1000),
},
});
}
Step 7 — The Customer Portal
app/actions/portal.ts"use server";
import { stripe } from "@/lib/stripe";
import { prisma } from "@/lib/prisma";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export async function openCustomerPortal(userId: string) {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user?.stripeCustomerId) throw new Error("No Stripe customer");
const origin = (await headers()).get("origin")!;
const portalSession = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: \`\${origin}/dashboard\`,
});
redirect(portalSession.url);
}
Step 8 — Reading Subscription Status in Server Components
// app/dashboard/page.tsx
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
export default async function Dashboard({ userId }: { userId: string }) {
const user = await prisma.user.findUnique({
where: { id: userId },
include: { subscription: true },
});
const isActive =
user?.subscription?.status === "active" ||
user?.subscription?.status === "trialing";
if (!isActive) redirect("/pricing");
return (
<div className="p-8">
<h1>Welcome to the Pro Plan</h1>
<p>
Your subscription renews on{" "}
{user!.subscription!.currentPeriodEnd.toLocaleDateString()}
</p>
</div>
);
}
Step 9 — Production Checklist
- Switch to Live Stripe Keys: Replace andtext
sk_test_withtextpk_test_andtextsk_live_in your production environment. Never commit live keys to git.textpk_live_ - Register Production Webhook: Add a new endpoint pointing to your production URL in Dashboard → Webhooks. Test and live webhooks have different signing secrets.
- Run in CI/CD: Never runtext
prisma migrate deployin production. Usetextmigrate devin your build step to apply pending migrations safely.textmigrate deploy - Use Connection Pooling for Serverless: Vercel + Prisma without pooling will exhaust connections fast. Use Prisma Accelerate, Neon's pooler, or PgBouncer.
- Verify Idempotency Works: Trigger the same Stripe event twice with and confirm only one DB row is written.text
stripe trigger - Enable Stripe Tax: Turn it on in Dashboard → Tax to handle VAT, GST, and US sales tax automatically.
- Monitor Failed Webhooks: Set up Slack or email alerts on the Stripe Dashboard for webhook failures — you want to know within minutes, not days.
- Test the Full Failure Path: Use card (succeeds then disputes) andtext
4000 0000 0000 0341(insufficient funds) before going live.text4000 0000 0000 9995
Conclusion
Skip the setup and start shipping
Love this guide? All these patterns are pre-configured in our **SaaS Starter Pro** kit. Save 40+ hours of development.
Explore the KitRelated Articles
Selected insights to level up your development workflow.
How to Add Stripe to Next.js (2026)
A complete walkthrough of integrating Stripe Checkout and webhooks into your Next.js application.
How to Add Razorpay to Next.js (2026): Complete Guide with Code
Step-by-step guide to integrate Razorpay payment gateway in Next.js 15 with App Router, TypeScript, webhooks, and refunds.
Next.js Authentication with Clerk — Complete Guide
Why Clerk is our top choice for SaaS authentication and how to set it up in under 15 minutes.
Keep building with free resources
Production-ready starter kits and zero-friction developer tools — the same ones we use to ship our own products.
Starter Kits
Next.js Blog Kit
MDX-powered blog with full SEO, dark mode, RSS feed, reading time, and syntax highlighting. Deploy to Vercel in one click.
Developer Tools
Shadcn/UI Component Previewer
Live preview of shadcn/ui components with instant copy-paste code. Browse rendered components and grab snippets.
Next.js Project Structure Generator
8.5kSelect your stack and instantly get a production-ready folder structure. Copy the entire scaffold in one click.
.env File Generator
24kPick your tech stack and get a complete, commented .env boilerplate file. Never forget an environment variable.
Tailwind CSS Color Palette Generator
15k+Enter a brand color and generate a complete Tailwind-compatible shade scale with config snippets.
Looking for something specific?
Browse the full library — 7+ kits across 4+ categories.