React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

nextjssaasauth-jsprismastripetailwindshadcnzodstarter

Next.js SaaS Starter

A complete SaaS starter stack — Next.js 15 + React 19 + Auth.js + Prisma + Stripe + Tailwind + shadcn/ui — the full production-ready setup you can build a real product on.

Recipe

Quick-reference recipe card — copy-paste ready.

# 1. Scaffold Next.js 15 with Tailwind and TypeScript
npx create-next-app@latest my-saas --typescript --tailwind --app --turbopack
cd my-saas
 
# 2. Add shadcn/ui
npx shadcn@latest init
npx shadcn@latest add button card input form dialog
 
# 3. Install the rest of the stack
npm install next-auth@beta @auth/prisma-adapter
npm install @prisma/client
npm install -D prisma
npm install stripe @stripe/stripe-js
npm install zod react-hook-form @hookform/resolvers
 
# 4. Initialize Prisma (Postgres)
npx prisma init --datasource-provider postgresql
 
# 5. Generate Auth.js secret
npx auth secret
// package.json — the production-ready dependency set
\{
  "dependencies": \{
    "@auth/prisma-adapter": "^2.7.0",
    "@hookform/resolvers": "^3.9.0",
    "@prisma/client": "^6.0.0",
    "@stripe/stripe-js": "^5.0.0",
    "next": "15.1.0",
    "next-auth": "5.0.0-beta.25",
    "react": "19.0.0",
    "react-dom": "19.0.0",
    "react-hook-form": "^7.54.0",
    "stripe": "^17.4.0",
    "zod": "^3.24.0"
  \},
  "devDependencies": \{
    "@types/node": "^22.10.0",
    "@types/react": "^19.0.0",
    "prisma": "^6.0.0",
    "tailwindcss": "^4.0.0",
    "typescript": "^5.6.3"
  \}
\}

When to reach for this: When you're starting a real SaaS product and want the canonical stack — database, auth, payments, UI kit, and validation — wired together correctly from day one.

Working Example

my-saas/
  app/
    (auth)/
      login/page.tsx
      register/page.tsx
    (dashboard)/
      layout.tsx
      dashboard/page.tsx
      billing/page.tsx
    api/
      auth/[...nextauth]/route.ts
      webhooks/
        stripe/route.ts
  lib/
    prisma.ts
    stripe.ts
    auth.ts
    validations.ts
  prisma/
    schema.prisma
  auth.ts
  middleware.ts
  .env.local
// prisma/schema.prisma
generator client \{
  provider = "prisma-client-js"
\}
 
datasource db \{
  provider = "postgresql"
  url      = env("DATABASE_URL")
\}
 
model User \{
  id               String    @id @default(cuid())
  name             String?
  email            String    @unique
  emailVerified    DateTime?
  image            String?
  stripeCustomerId String?   @unique
  accounts         Account[]
  sessions         Session[]
  subscription     Subscription?
  createdAt        DateTime  @default(now())
  updatedAt        DateTime  @updatedAt
\}
 
model Account \{
  id                String  @id @default(cuid())
  userId            String
  type              String
  provider          String
  providerAccountId String
  refresh_token     String?
  access_token      String?
  expires_at        Int?
  token_type        String?
  scope             String?
  id_token          String?
  session_state     String?
  user              User    @relation(fields: [userId], references: [id], onDelete: Cascade)
 
  @@unique([provider, providerAccountId])
\}
 
model Session \{
  id           String   @id @default(cuid())
  sessionToken String   @unique
  userId       String
  expires      DateTime
  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)
\}
 
model Subscription \{
  id                   String   @id @default(cuid())
  userId               String   @unique
  stripeSubscriptionId String   @unique
  stripePriceId        String
  status               String   // active, trialing, past_due, canceled, etc.
  currentPeriodEnd     DateTime
  user                 User     @relation(fields: [userId], references: [id], onDelete: Cascade)
\}
// lib/prisma.ts — singleton to survive hot reload
import \{ PrismaClient \} from "@prisma/client";
 
const globalForPrisma = globalThis as unknown as \{
  prisma: PrismaClient | undefined;
\};
 
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.ts
import Stripe from "stripe";
 
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, \{
  apiVersion: "2024-12-18.acacia",
  typescript: true,
\});
// auth.ts — Auth.js v5 (beta) config
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
import \{ PrismaAdapter \} from "@auth/prisma-adapter";
import \{ prisma \} from "@/lib/prisma";
import \{ stripe \} from "@/lib/stripe";
 
export const \{ handlers, auth, signIn, signOut \} = NextAuth(\{
  adapter: PrismaAdapter(prisma),
  session: \{ strategy: "database" \},
  providers: [GitHub],
  events: \{
    async createUser(\{ user \}) \{
      // Create a Stripe customer the moment the user signs up
      const customer = await stripe.customers.create(\{
        email: user.email ?? undefined,
        name: user.name ?? undefined,
        metadata: \{ userId: user.id! \},
      \});
      await prisma.user.update(\{
        where: \{ id: user.id \},
        data: \{ stripeCustomerId: customer.id \},
      \});
    \},
  \},
  callbacks: \{
    async session(\{ session, user \}) \{
      const sub = await prisma.subscription.findUnique(\{ where: \{ userId: user.id \} \});
      session.user.id = user.id;
      session.user.stripeCustomerId = (user as any).stripeCustomerId ?? null;
      session.user.subscriptionStatus = sub?.status ?? "none";
      return session;
    \},
  \},
\});
// app/api/auth/[...nextauth]/route.ts
export \{ GET, POST \} from "@/auth";
// lib/validations.ts
import \{ z \} from "zod";
 
export const checkoutSchema = z.object(\{
  priceId: z.string().startsWith("price_"),
\});
export type CheckoutInput = z.infer<typeof checkoutSchema>;
// app/(dashboard)/billing/actions.ts — Server Action: start a Checkout Session
"use server";
 
import \{ redirect \} from "next/navigation";
import \{ auth \} from "@/auth";
import \{ stripe \} from "@/lib/stripe";
import \{ checkoutSchema \} from "@/lib/validations";
 
export async function createCheckoutSession(formData: FormData) \{
  const session = await auth();
  if (!session?.user?.stripeCustomerId) throw new Error("Not authenticated");
 
  const parsed = checkoutSchema.parse(\{ priceId: formData.get("priceId") \});
 
  const checkout = await stripe.checkout.sessions.create(\{
    customer: session.user.stripeCustomerId,
    mode: "subscription",
    line_items: [\{ price: parsed.priceId, quantity: 1 \}],
    success_url: `$\{process.env.APP_URL\}/dashboard?checkout=success`,
    cancel_url: `$\{process.env.APP_URL\}/billing?checkout=cancelled`,
    metadata: \{ userId: session.user.id \},
  \});
 
  if (!checkout.url) throw new Error("No checkout URL");
  redirect(checkout.url);
\}
// app/api/webhooks/stripe/route.ts — Node runtime, NOT edge
import \{ NextRequest, NextResponse \} from "next/server";
import Stripe from "stripe";
import \{ stripe \} from "@/lib/stripe";
import \{ prisma \} from "@/lib/prisma";
 
export const runtime = "nodejs"; // critical — webhooks need Node crypto
export const dynamic = "force-dynamic";
 
export async function POST(req: NextRequest) \{
  const body = await req.text();
  const signature = req.headers.get("stripe-signature");
  if (!signature) return new NextResponse("No signature", \{ status: 400 \});
 
  let event: Stripe.Event;
  try \{
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!,
    );
  \} catch (err) \{
    return new NextResponse(`Webhook error: $\{(err as Error).message\}`, \{ status: 400 \});
  \}
 
  switch (event.type) \{
    case "checkout.session.completed":
    case "customer.subscription.updated":
    case "customer.subscription.created": \{
      const sub = event.data.object as Stripe.Subscription;
      const userId = (sub.metadata?.userId as string) ?? null;
      if (!userId) break;
      await prisma.subscription.upsert(\{
        where: \{ userId \},
        create: \{
          userId,
          stripeSubscriptionId: sub.id,
          stripePriceId: sub.items.data[0]!.price.id,
          status: sub.status,
          currentPeriodEnd: new Date(sub.current_period_end * 1000),
        \},
        update: \{
          stripeSubscriptionId: sub.id,
          stripePriceId: sub.items.data[0]!.price.id,
          status: sub.status,
          currentPeriodEnd: new Date(sub.current_period_end * 1000),
        \},
      \});
      break;
    \}
    case "customer.subscription.deleted": \{
      const sub = event.data.object as Stripe.Subscription;
      await prisma.subscription.updateMany(\{
        where: \{ stripeSubscriptionId: sub.id \},
        data: \{ status: "canceled" \},
      \});
      break;
    \}
  \}
 
  return NextResponse.json(\{ received: true \});
\}
// middleware.ts — protect dashboard
import \{ auth \} from "@/auth";
 
export default auth((req) => \{
  if (!req.auth && req.nextUrl.pathname.startsWith("/dashboard")) \{
    const url = new URL("/login", req.url);
    return Response.redirect(url);
  \}
\});
 
export const config = \{ matcher: ["/dashboard/:path*", "/billing/:path*"] \};
# .env.local — required environment variables
DATABASE_URL="postgresql://user:pass@localhost:5432/mysaas"
AUTH_SECRET="generated-by-npx-auth-secret"
AUTH_GITHUB_ID="..."
AUTH_GITHUB_SECRET="..."
STRIPE_SECRET_KEY="sk_test_..."
STRIPE_WEBHOOK_SECRET="whsec_..."
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_..."
APP_URL="http://localhost:3000"

What this demonstrates:

  • Server Action calls Stripe API with the server key, then redirect() to Checkout
  • Webhook endpoint runs on Node runtime, verifies the signature, and writes subscription state to Postgres
  • Auth.js events.createUser creates a Stripe customer at signup so stripeCustomerId is always present
  • Session callback augments session.user with billing info for UI gating
  • Prisma singleton avoids connection storms during hot reload
  • Zod schema validates form input before hitting Stripe

Deep Dive

How It Works

  • Auth.js v5 exposes auth(), handlers, signIn, signOut from a single config file. handlers is re-exported by a catch-all route at app/api/auth/[...nextauth]/route.ts.
  • The PrismaAdapter tells Auth.js to store users, sessions, and accounts in your database. Pairing it with session: \{ strategy: "database" \} means sessions live in Postgres, not JWTs.
  • At signup, the events.createUser hook creates a Stripe customer and writes stripeCustomerId back to User. Now every future checkout uses the same customer record, which is critical for subscription management.
  • A Server Action builds a Stripe Checkout Session on the server (using the secret key) and returns a URL. redirect() sends the browser to Stripe.
  • After payment, Stripe POSTs to /api/webhooks/stripe. The handler verifies the signature using the raw body, parses the event, and upserts the Subscription row.
  • The session callback reads the subscription row on every request, so session.user.subscriptionStatus reflects the latest billing state without extra client fetches.

Variations

Use an existing starter instead of building from scratch:

  • Vercel's Next.js SaaS Starter — pnpm create next-app --example next-saas-starter
  • Taxonomy by shadcn — open-source reference app with Auth.js + Prisma
  • Shipixen — paid code generator for the same stack
  • T3 Stack (create-t3-app) — Next.js + tRPC + Prisma + NextAuth, add Stripe manually

Team / organization support:

model Organization \{
  id       String  @id @default(cuid())
  name     String
  members  Member[]
  subscription Subscription?
\}
 
model Member \{
  id             String       @id @default(cuid())
  userId         String
  organizationId String
  role           String       // owner | admin | member
  organization   Organization @relation(fields: [organizationId], references: [id])
 
  @@unique([userId, organizationId])
\}

Move stripeCustomerId and Subscription onto Organization so billing is per-workspace.

Row-level security with Supabase: use @supabase/ssr + RLS policies instead of Prisma for data you want protected at the database level.

Multi-tenancy patterns: subdomain routing ([tenant].app.com) via middleware rewrite, or path-based (/org/[slug]). Store tenantId on every row.

Email with Resend:

import \{ Resend \} from "resend";
const resend = new Resend(process.env.RESEND_API_KEY!);
await resend.emails.send(\{ from: "hi@app.com", to: user.email, subject: "Welcome", react: <Welcome /> \});

Background jobs with Inngest: offload webhook side effects (sending welcome emails, provisioning resources) from the webhook handler so Stripe gets a 200 fast.

TypeScript Notes

// types/next-auth.d.ts — augment the Session type
import \{ DefaultSession \} from "next-auth";
 
declare module "next-auth" \{
  interface Session \{
    user: \{
      id: string;
      stripeCustomerId: string | null;
      subscriptionStatus: "active" | "trialing" | "past_due" | "canceled" | "none";
    \} & DefaultSession["user"];
  \}
\}
// Typed Stripe webhook event narrowing
import type Stripe from "stripe";
 
function isSubscriptionEvent(
  event: Stripe.Event,
): event is Stripe.Event & \{ data: \{ object: Stripe.Subscription \} \} \{
  return event.type.startsWith("customer.subscription.");
\}
// Zod schema → form input type in one line
import \{ z \} from "zod";
const pricingFormSchema = z.object(\{ priceId: z.string().startsWith("price_") \});
type PricingFormValues = z.infer<typeof pricingFormSchema>;

Gotchas

  • Stripe webhook signatures must be verified — calling JSON.parse(await req.text()) without constructEvent means anyone who guesses your URL can mutate your database. Fix: always call stripe.webhooks.constructEvent(rawBody, signature, secret) and pass the raw body string, not a parsed object.

  • Webhook endpoint cannot use the edge runtime — Stripe's Node SDK uses crypto modules that don't exist on the edge. The signature verification will throw crypto.createHmac is not a function. Fix: set export const runtime = "nodejs" on the webhook route.

  • Prisma client must be a singleton in dev — without it, every hot reload spawns a new client and Postgres eventually refuses connections with "too many clients". Fix: use the globalThis singleton pattern in lib/prisma.ts.

  • Auth.js + Stripe customer ID sync must happen at signup, not checkout — if you create the Stripe customer lazily on first checkout, you hit race conditions (double-click the upgrade button, get two customers). Fix: use events.createUser to provision the customer atomically when the user is created.

  • Environment variables for dev vs prod Stripe keys — committing test keys to .env.production (or forgetting to swap the webhook secret) will silently fail signature verification in production. Fix: use separate Stripe accounts/modes, separate webhook endpoints, and separate env vars for each environment. Never reuse STRIPE_WEBHOOK_SECRET across environments.

  • Subscription status changes are asynchronous — a user pays, gets redirected to success_url, and your app happily shows "Pro features" — but the webhook hasn't arrived yet, so the DB still says status: "none". Fix: either (a) wait for the webhook before granting access (poll on the success page), or (b) trust the Checkout Session result optimistically and reconcile via the webhook within seconds.

  • Server Actions must guard against unauthenticated callers"use server" does not check auth for you. Every action must call auth() and throw if the session is missing. Fix: wrap actions in a requireUser() helper.

  • Middleware and auth() together — calling auth() inside middleware on the edge works, but calling Prisma from middleware does not (Prisma isn't edge-compatible). Fix: keep middleware to session checks only; do DB lookups in page/route handlers.

Alternatives

AlternativeUse WhenDon't Use When
ShipixenYou want a polished, opinionated starter generated from a wizardYou want to own every line of code
Makerkit (paid)You want a production-grade SaaS template with teams, billing, and blog pre-builtBudget constraints or fully open-source requirement
Vercel CommerceYou're building an e-commerce store, not a subscription SaaSYou need auth + per-user subscriptions
Vercel Next.js SaaS StarterYou want the canonical Vercel reference — Next.js + Postgres + Stripe + DrizzleYou prefer Prisma over Drizzle
Railway's Next.js templateYou want Railway-hosted Postgres wired up automaticallyYou're deploying to Vercel or Cloudflare
T3 Stack + manual StripeYou want tRPC and strong type safety across the client/server boundaryYou prefer REST or Server Actions over tRPC

FAQs

Why does the Stripe webhook route set runtime = "nodejs"?

Because stripe.webhooks.constructEvent relies on Node's crypto module to verify HMAC signatures, which the edge runtime does not provide. Running on the edge will throw at the first webhook.

Why create the Stripe customer in events.createUser instead of at checkout?

It guarantees every user has exactly one stripeCustomerId from the moment they sign up, eliminating race conditions and duplicate customer records when users click "Upgrade" multiple times or refresh mid-checkout.

Why wrap PrismaClient in a globalThis singleton?

Next.js hot reload re-evaluates modules on every file change in dev. Without the singleton, each reload creates a new PrismaClient, each opening its own pool. Postgres quickly hits its connection limit and throws "too many clients."

How do I test Stripe webhooks locally?

Install the Stripe CLI, run stripe listen --forward-to localhost:3000/api/webhooks/stripe, copy the printed whsec_... into STRIPE_WEBHOOK_SECRET, then trigger events with stripe trigger checkout.session.completed.

What's the difference between session: \{ strategy: "database" \} and "jwt"?

database stores sessions in your DB via the adapter — you can revoke a single session from the server. jwt stores everything in a signed cookie — no DB round-trip, but revocation requires changing AUTH_SECRET, which nukes every session.

Why is session.user.subscriptionStatus computed in the session callback?

So every page and Server Action that calls auth() automatically sees the freshest billing status without having to query Prisma separately. The trade-off is one extra query per request; cache it if needed.

Gotcha: my webhook runs twice for the same event — why?

Stripe retries webhooks that don't return 2xx within 30 seconds. Make the handler idempotent: use upsert keyed on stripeSubscriptionId, and return 200 immediately, offloading slow work to a background job.

Gotcha: I granted Pro access on the success_url page but the user isn't Pro. What happened?

The webhook hadn't landed when the redirect hit, so subscriptionStatus was still none. Either poll on the success page until the DB updates, or fetch the Checkout Session directly with stripe.checkout.sessions.retrieve(id) and trust the payment_status.

TypeScript: how do I add stripeCustomerId to session.user?

Create types/next-auth.d.ts and use declare module "next-auth" to augment the Session interface. Include the file in your tsconfig.json via "include". Auth.js v5 reads module augmentation at build time.

TypeScript: how do I narrow a Stripe webhook event to a Subscription event?

Write a user-defined type guard:

function isSubscriptionEvent(
  e: Stripe.Event,
): e is Stripe.Event & \{ data: \{ object: Stripe.Subscription \} \} \{
  return e.type.startsWith("customer.subscription.");
\}

Then if (isSubscriptionEvent(event)) \{ ... \} gives you a typed event.data.object inside the block.

Should I put stripeCustomerId on User or Organization?

On User if billing is per-user (personal plans). On Organization if billing is per-workspace (team plans) — which is the normal SaaS model. You can start on User and migrate later, but it's easier to design for organizations from the beginning.

Can I use Drizzle or Kysely instead of Prisma?

Yes. Auth.js has @auth/drizzle-adapter and a Kysely adapter. The rest of this recipe (Stripe, Server Actions, webhook handling) is ORM-agnostic. Drizzle is lighter at the edge; Prisma has better DX and a richer ecosystem.