DevKit Market
  • Home
  • Categories
  • Products
  • Tools
  • Claude skills
  • Blog
  • About
Sign inGet started
DevKit Market
HomeCategoriesProductsToolsClaude skillsBlogAbout
Theme
Sign inGet started
DevKit Market

Production-ready starter kits for developers who want to ship faster, not fiddle with boilerplate.

Products

SaaS Starter ProNext.js Blog KitAuth BoilerplateLanding Page KitAdmin DashboardWaitlist App

Company

Hire meBlogClaude skillsAbout

Support

FAQContact

© 2026 DevKit Market. Built solo with Next.js & Claude.

Sitemap
Blog/Tutorial/Next.js + Prisma + Stripe Tutorial
Tutorial
April 12, 2026•8 min read

Next.js + Prisma + Stripe Tutorial

Nikhil Anand
Lead Developer @ DevKit

Next.js + Prisma + Stripe Tutorial (2026): Build a Production SaaS in One Sitting

Building a production SaaS in Next.js has changed significantly with the arrival of the App Router, Server Actions, and Prisma 6. Gone are the days of stitching together API routes, manual SQL migrations, and brittle webhook handlers; today, we leverage Prisma's typed client, Stripe Embedded Checkout, and Next.js Server Actions to build a complete subscription SaaS in under three hours.
This tutorial walks you through a production-ready setup for Next.js 15 + Prisma 6 + Stripe, including database schema design, type-safe Server Actions, Embedded Checkout, webhook fulfillment with idempotency, and the Customer Portal for self-service billing.
Live demo: saas-demo.devkitmarket.com GitHub repo: github.com/devkit-market/nextjs-prisma-stripe-2026

5-Minute Overview — The Workflow

  1. Project Setup: Bootstrap Next.js 15 with TypeScript, Tailwind, and Prisma 6.
  2. Database Schema: Define User, Subscription, and Payment models in Prisma.
  3. Singletons: Create typed Prisma and Stripe clients for the whole app.
  4. Checkout Action: Build a Server Action that creates a Stripe session.
  5. Embedded Checkout UI: Mount Stripe inside your own page (no redirects).
  6. Webhook Handler: Verify events, write to Prisma, and handle idempotency.
  7. Customer Portal: Let users manage their subscription with one route.

Step 1 — Project Setup & Dependencies

First, bootstrap a fresh Next.js 15 project and install everything you need:
bash
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
Add your environment variables to
text
.env.local
. Never expose the Stripe Secret Key or DB URL to the client — only
text
NEXT_PUBLIC_
variables are safe for the browser.
bash
# .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
Don't have Postgres locally? Spin up a free instance on Neon, Supabase, or Prisma Postgres and paste the connection string into
text
DATABASE_URL
.

Step 2 — Define Your Prisma Schema

Open
text
prisma/schema.prisma
and define the three models every subscription SaaS needs:
text
User
,
text
Subscription
, and
text
WebhookEvent
(for idempotency).
prisma
generator 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())
}
Push this schema to your database and generate the typed client:
bash
npx prisma migrate dev --name init
npx prisma generate
The
text
@unique
constraint on
text
stripeId
in
text
WebhookEvent
is what makes idempotency bulletproof — even if Stripe sends the same event twice, the second insert fails cleanly.

Step 3 — Singleton Clients (Critical)

Both Prisma and Stripe should be instantiated once for your whole app. In Next.js dev mode, hot reload re-imports modules constantly — without singletons, you'll exhaust your DB connection pool in minutes.
Create
text
lib/prisma.ts
:
typescript
import { 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;
Create
text
lib/stripe.ts
:
typescript
import 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,
});
Pinning the Stripe API version is critical. "Latest" will silently break your integration the day Stripe ships a new version.

Step 4 — The Checkout Server Action

In Next.js 15, we use Server Actions to create the Stripe Checkout Session. Cleaner than API routes, fully type-safe end to end.
Create
text
app/actions/checkout.ts
:
typescript
"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

Now mount Stripe's checkout component inside your own page. Create
text
app/checkout/page.tsx
:
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">
      &lt;EmbeddedCheckoutProvider
        stripe={stripePromise}
        options={{ fetchClientSecret }}
      >
        &lt;EmbeddedCheckout />
      &lt;/EmbeddedCheckoutProvider>
    </div>
  );
}

Step 6 — Webhook Handler with Prisma Sync (Critical)

The browser redirect is not 100% reliable. If the user closes their tab between paying and the return URL firing, you'll never know they paid. To ensure your database stays in sync, you must use webhooks as the source of truth.
Create
text
app/api/webhook/stripe/route.ts
:
typescript
import { 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

For subscriptions, customers need to update their card, cancel, switch plans, and download invoices. Building this UI yourself is weeks of work. Stripe gives it to you in one Server Action.
Create
text
app/actions/portal.ts
:
typescript
"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

Now use Prisma to gate features based on subscription status.
tsx
// 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 (
    &lt;div className="p-8">
      &lt;h1>Welcome to the Pro Plan&lt;/h1>
      &lt;p>
        Your subscription renews on{" "}
        {user!.subscription!.currentPeriodEnd.toLocaleDateString()}
      &lt;/p>
    &lt;/div>
  );
}

Step 9 — Production Checklist

Before you ship to Live Mode, ensure you've ticked these boxes:
  • Switch to Live Stripe Keys: Replace
    text
    sk_test_
    and
    text
    pk_test_
    with
    text
    sk_live_
    and
    text
    pk_live_
    in your production environment. Never commit live keys to git.
  • Register Production Webhook: Add a new endpoint pointing to your production URL in Dashboard → Webhooks. Test and live webhooks have different signing secrets.
  • Run
    text
    prisma migrate deploy
    in CI/CD
    : Never run
    text
    migrate dev
    in production. Use
    text
    migrate deploy
    in your build step to apply pending migrations safely.
  • 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
    text
    stripe trigger
    and confirm only one DB row is written.
  • 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
    text
    4000 0000 0000 0341
    (succeeds then disputes) and
    text
    4000 0000 0000 9995
    (insufficient funds) before going live.

Conclusion

Next.js + Prisma + Stripe is the most productive subscription SaaS stack in 2026 for a reason — you get end-to-end type safety, fast database-backed entitlement checks, and Stripe's industry-leading payment infrastructure with one webhook handler tying it all together. By combining them with Server Actions and Embedded Checkout, you ship a complete billing layer in one focused afternoon.
Need a pre-built template with this already configured? Check out our SaaS Starter Pro which includes Next.js 15, Prisma 6, Stripe Subscriptions, the Customer Portal, and Webhook Idempotency out of the box.

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 Kit

Related Articles

Selected insights to level up your development workflow.

View all
Tutorial
5 min

How to Add Stripe to Next.js (2026)

A complete walkthrough of integrating Stripe Checkout and webhooks into your Next.js application.

Read more
Tutorial
12 min

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.

Read more
Tutorial
10 min

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.

Read more
Browse all articles
Free for everyoneno signup · no credit card

Keep building with free resources

Production-ready starter kits and zero-friction developer tools — the same ones we use to ship our own products.

4 kits
9 tools

Starter Kits

clone · ship
FreeFeatured

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.

Next.jsMDXTailwind
Get kit

Landing Page Kit

Free

Conversion-optimised landing page with hero, pricing, testimonials, FAQ, waitlist form, and analytics integration built in.

Waitlist App

Free

Viral referral waitlist with position tracking, email confirmation, social share, and a live Supabase backend. Zero to launch in an hour.

Developer Tools

instant · in-browser
12k+
usage / mo

Shadcn/UI Component Previewer

Live preview of shadcn/ui components with instant copy-paste code. Browse rendered components and grab snippets.

Productivity
Open tool

Next.js Project Structure Generator

8.5k

Select your stack and instantly get a production-ready folder structure. Copy the entire scaffold in one click.

.env File Generator

24k

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

Browse all resources
Back to blog
Share article