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.createUsercreates a Stripe customer at signup sostripeCustomerIdis always present - Session callback augments
session.userwith 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,signOutfrom a single config file.handlersis re-exported by a catch-all route atapp/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.createUserhook creates a Stripe customer and writesstripeCustomerIdback toUser. 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 theSubscriptionrow. - The session callback reads the subscription row on every request, so
session.user.subscriptionStatusreflects 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())withoutconstructEventmeans anyone who guesses your URL can mutate your database. Fix: always callstripe.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
cryptomodules that don't exist on the edge. The signature verification will throwcrypto.createHmac is not a function. Fix: setexport 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
globalThissingleton pattern inlib/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.createUserto 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 reuseSTRIPE_WEBHOOK_SECRETacross 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 saysstatus: "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 callauth()and throw if the session is missing. Fix: wrap actions in arequireUser()helper. -
Middleware and
auth()together — callingauth()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
| Alternative | Use When | Don't Use When |
|---|---|---|
| Shipixen | You want a polished, opinionated starter generated from a wizard | You want to own every line of code |
| Makerkit (paid) | You want a production-grade SaaS template with teams, billing, and blog pre-built | Budget constraints or fully open-source requirement |
| Vercel Commerce | You're building an e-commerce store, not a subscription SaaS | You need auth + per-user subscriptions |
| Vercel Next.js SaaS Starter | You want the canonical Vercel reference — Next.js + Postgres + Stripe + Drizzle | You prefer Prisma over Drizzle |
| Railway's Next.js template | You want Railway-hosted Postgres wired up automatically | You're deploying to Vercel or Cloudflare |
| T3 Stack + manual Stripe | You want tRPC and strong type safety across the client/server boundary | You 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.
Related
- Environment Variables — managing dev and prod secrets safely
- Authentication — Auth.js patterns and route protection
- Server Actions — calling Stripe from Server Actions
- Prisma — schema design and query patterns
- Stripe Setup — Stripe SDK configuration basics
- Turborepo Monorepo — scale this starter to multiple apps