Build a SaaS with Next.js, Postgres and Stripe
Build a complete subscription SaaS from scratch with Next.js, PostgreSQL and Stripe: auth, billing, onboarding and deployment to a VPS.
Build a production-ready subscription SaaS from nothing: Next.js app, PostgreSQL database, Stripe billing, signed session cookies, a complete middleware guard and a deployable Docker image.
What we’re building
flowchart LR
User --> Next[Next.js (App Router)]
Next --> PG[(PostgreSQL)]
Next --> Stripe[Stripe]
Next --> Resend[Resend emails]
What you’ll learn
- Stripe subscriptions: checkout, webhooks, and automatic downgrades
- Auth without a BaaS: signed session cookies, protected middleware, and honest RLS guidance
- The exact file layout of a modern Next.js SaaS that actually deploys
Prerequisites
- Node.js 22+
- PostgreSQL 17
- Stripe account (test keys)
- A VPS with 2 GB RAM
1. Scaffold
npx create-next-app@latest saas --ts --app --tailwind
cd saas
npm i stripe @prisma/client prisma bcryptjs zod
Enable the standalone output we’ll need for the Docker image later:
next.config.ts:
const nextConfig = {
output: 'standalone',
};
export default nextConfig;
2. Prisma schema
prisma/schema.prisma:
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
plan String @default("free")
stripeId String?
createdAt DateTime @default(now())
}
lib/prisma.ts:
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
The globalForPrisma guard prevents Prisma from opening a new connection pool on every hot reload in dev.
Migrate:
npx prisma migrate dev --name init
npx prisma generate
3. Session signing
We hand-roll auth with a stateless, HMAC-signed cookie — no Auth.js, no extra dependencies, and it can be verified in the edge middleware without a database call.
lib/session.ts:
import { createHmac, timingSafeEqual } from 'node:crypto';
const SECRET = process.env.SESSION_SECRET;
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
export function signSession(userId: string): string {
const payload = Buffer.from(
JSON.stringify({ uid: userId, exp: Date.now() + MAX_AGE_MS })
).toString('base64url');
const sig = createHmac('sha256', SECRET!).update(payload).digest('base64url');
return `${payload}.${sig}`;
}
export function verifySession(token: string | undefined): string | null {
if (!token) return null;
const [payload, sig] = token.split('.');
if (!payload || !sig) return null;
const expected = createHmac('sha256', SECRET!).update(payload).digest('base64url');
if (sig.length !== expected.length || !timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return null;
}
const { uid, exp } = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as {
uid: string;
exp: number;
};
if (Date.now() > exp) return null;
return uid;
}
4. Auth routes
app/api/auth/signup/route.ts:
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { hash } from 'bcryptjs';
import { prisma } from '@/lib/prisma';
import { signSession } from '@/lib/session';
const bodySchema = z.object({
email: z.string().email(),
password: z.string().min(8).max(128),
});
export async function POST(req: NextRequest) {
try {
const parsed = bodySchema.safeParse(await req.json());
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const email = parsed.data.email.toLowerCase().trim();
const exists = await prisma.user.findUnique({ where: { email } });
if (exists) {
return NextResponse.json({ error: 'email already registered' }, { status: 409 });
}
const user = await prisma.user.create({
data: { email, passwordHash: await hash(parsed.data.password, 12) },
});
const res = NextResponse.json({ ok: true });
res.cookies.set('session', signSession(user.id), {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
});
return res;
} catch (err) {
console.error('signup failed', err);
return NextResponse.json({ error: 'internal error' }, { status: 500 });
}
}
app/api/auth/login/route.ts:
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { compare } from 'bcryptjs';
import { prisma } from '@/lib/prisma';
import { signSession } from '@/lib/session';
const bodySchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
export async function POST(req: NextRequest) {
try {
const parsed = bodySchema.safeParse(await req.json());
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const user = await prisma.user.findUnique({
where: { email: parsed.data.email.toLowerCase().trim() },
});
if (!user || !(await compare(parsed.data.password, user.passwordHash))) {
return NextResponse.json({ error: 'invalid credentials' }, { status: 401 });
}
const res = NextResponse.json({ ok: true });
res.cookies.set('session', signSession(user.id), {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
});
return res;
} catch (err) {
console.error('login failed', err);
return NextResponse.json({ error: 'internal error' }, { status: 500 });
}
}
app/api/auth/logout/route.ts:
import { NextResponse } from 'next/server';
export async function POST() {
const res = NextResponse.json({ ok: true });
res.cookies.set('session', '', { maxAge: 0, path: '/' });
return res;
}
5. Row-level security (what “RLS” actually gives you)
This guide’s app uses a single Postgres role via Prisma, where app-level checks are the enforcement layer. RLS matters when multiple apps share one database or you expose PostgREST/Supabase-style APIs. When you need it:
-- Apply to a multi-tenant table, run as the table owner.
ALTER TABLE org_members ENABLE ROW LEVEL SECURITY;
CREATE POLICY "members read own rows" ON org_members
FOR SELECT USING (user_id = current_setting('app.current_user_id', true)::uuid);
CREATE POLICY "members update own rows" ON org_members
FOR UPDATE USING (user_id = current_setting('app.current_user_id', true)::uuid);
The database then refuses any row the current user doesn’t own, even if application code has a bugging query. The catch: the pool’s single Postgres role doesn’t know who the HTTP user is, so you must set the identity inside each transaction:
BEGIN;
SELECT set_config('app.current_user_id', '<user_id>', true);
-- SELECT/UPDATE here are restricted by the policies above
COMMIT;
With Prisma, run that via $executeRaw/$queryRaw inside an interactiveTransactions block; with PostgREST/Supabase the JWT supplies the identity automatically. For this single-app SaaS, RLS is the right answer only if a second app or direct DB access will exist later — not mandatory now.
6. Stripe subscriptions
First, get a price ID — the step most guides skip:
- Stripe Dashboard → Test mode → Products → Add product (“Pro”, $9/month recurring) → Create.
- Open the new product → API ID / Pricing → copy the Price ID (
price_1...). - Put it in
.env:STRIPE_PRO_PRICE=price_1....
lib/billing.ts:
import { NextRequest } from 'next/server';
import Stripe from 'stripe';
import { prisma } from '@/lib/prisma';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function ensureCustomer(userId: string, email: string) {
const existing = await prisma.user.findUnique({
where: { id: userId },
select: { stripeId: true },
});
if (existing?.stripeId) return existing.stripeId;
const customer = await stripe.customers.create({ email, metadata: { userId } });
await prisma.user.update({ where: { id: userId }, data: { stripeId: customer.id } });
return customer.id;
}
export async function checkout(req: NextRequest, userId: string, email: string) {
const customer = await ensureCustomer(userId, email);
const origin = req.headers.get('origin') ?? process.env.NEXT_PUBLIC_APP_URL!;
const session = await stripe.checkout.sessions.create({
customer,
mode: 'subscription',
line_items: [{ price: process.env.STRIPE_PRO_PRICE!, quantity: 1 }],
metadata: { userId },
success_url: `${origin}/dashboard`,
cancel_url: `${origin}/pricing`,
allow_promotion_codes: true,
});
return session.url;
}
Notes:
- The
emailparameter is actually used now — no hardcoded address. metadata.userIdis how the webhook maps the Stripe event back to the user. Do not rely onsession.customer_email; if the customer already exists in Stripe, it can benull.success_url/cancel_urlcome from the request origin, so checkout works onlocalhostand in production without edits.
7. Webhook (the critical file)
app/api/webhooks/stripe/route.ts:
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { prisma } from '@/lib/prisma';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: NextRequest) {
const sig = req.headers.get('stripe-signature');
if (!sig) {
return NextResponse.json({ error: 'missing stripe-signature header' }, { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(await req.text(), sig, endpointSecret);
} catch (err) {
console.error('webhook signature verification failed', err);
return NextResponse.json({ error: 'invalid signature' }, { status: 400 });
}
try {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
if (!session.metadata?.userId) break;
await prisma.user.update({
where: { id: session.metadata.userId },
data: {
plan: 'pro',
stripeId: typeof session.customer === 'string' ? session.customer : String(session.customer),
},
});
break;
}
case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription;
await prisma.user.updateMany({
where: { stripeId: String(sub.customer) },
data: { plan: 'free' },
});
break;
}
}
} catch (err) {
console.error('webhook handler failed', err);
return NextResponse.json({ error: 'handler failed' }, { status: 500 });
}
return NextResponse.json({ received: true });
}
Return a non-2xx on failure so Stripe retries the event. Verify locally:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# STRIPE_WEBHOOK_SECRET is the whsec_... printed by that command
8. Middleware guard
middleware.ts:
import { NextRequest, NextResponse } from 'next/server';
import { verifySession } from '@/lib/session';
const PUBLIC_PATHS = [
'/login',
'/signup',
'/pricing',
'/api/auth/login',
'/api/auth/signup',
'/api/webhooks/stripe',
];
// Node.js runtime so node:crypto works here (Next.js >= 15.2).
export const runtime = 'nodejs';
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
if (PUBLIC_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`))) {
return NextResponse.next();
}
const token = request.cookies.get('session')?.value;
if (!verifySession(token)) {
const login = new URL('/login', request.url);
login.searchParams.set('next', pathname);
return NextResponse.redirect(login);
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\..*).*)'],
};
Signed cookies mean the guard runs without a database call; only page routes and API routes under the matcher are checked, and static assets are excluded.
9. Environment variables
| Variable | Required | Description |
|---|---|---|
DATABASE_URL |
yes | Postgres connection string |
SESSION_SECRET |
yes | ≥ 32 random chars: openssl rand -hex 32 |
STRIPE_SECRET_KEY |
yes | sk_test_... from Stripe dashboard |
STRIPE_WEBHOOK_SECRET |
yes | whsec_... from stripe listen |
STRIPE_PRO_PRICE |
yes | price_1... of the Pro product (see section 6) |
NEXT_PUBLIC_APP_URL |
no | e.g. https://saas.example.com in production |
10. Run locally
export DATABASE_URL=postgres://postgres:dev@localhost:5432/saas
export SESSION_SECRET=$(openssl rand -hex 32)
export STRIPE_SECRET_KEY=sk_test_...
export STRIPE_WEBHOOK_SECRET=whsec_...
export STRIPE_PRO_PRICE=price_1...
npm run dev
Verify: http://localhost:3000/pricing loads without redirect; a signed-in user can reach /dashboard; an anonymous /dashboard visit redirects to /login?next=/dashboard.
11. Dockerfile
Dockerfile:
FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npx prisma generate && npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
COPY --from=build /app/prisma ./prisma
EXPOSE 3000
CMD ["sh", "-c", "npx prisma migrate deploy && node server.js"]
prisma migrate deploy in the entrypoint applies pending migrations at startup — the fix for the classic “build passes locally, fails on the VPS” problem.
12. Deploy to VPS
docker build -t saas .
docker run -d --name saas --restart unless-stopped -p 3000:3000 \
-e DATABASE_URL=... -e SESSION_SECRET=... -e STRIPE_SECRET_KEY=... \
-e STRIPE_WEBHOOK_SECRET=... -e STRIPE_PRO_PRICE=... \
-e NEXT_PUBLIC_APP_URL=https://saas.example.com saas
Caddy:
saas.example.com {
reverse_proxy saas:3000
}
Cost (assumptions: 300 users, 50 paid)
| Service | Monthly cost |
|---|---|
| VPS 2 GB (Hetzner CX22) | $4.49 |
| PostgreSQL (on VPS) | $0 |
| Stripe (1.5% + $0.30) | ~$25 |
| Resend (3k emails free) | $0 |
| Total | ≈ $30/month |
Stripe fees assume ~$17 average monthly subscription × 50 paid customers.
Production checklist
- Webhook secret as env var, never in repo
- Rate limit signup + login (per IP) before launch
- Password hashing (bcrypt, cost 12)
-
SESSION_SECRET≥ 32 chars and rotated on breach - Daily
pg_dumpbackup + restore drill - Stripe test mode → live mode key swap
-
invoice.paid/invoice.payment_failedwebhook handlers for dunning
Common problems
- Webhook 400s: missing
STRIPE_WEBHOOK_SECRETor a payload replayed with the wrong secret — test withstripe listen. - Downgrade not applied:
customer.subscription.deletedonly fires on cancellation; addinvoice.payment_failed→ set apast_dueflag for failed payments. - Prisma + Next build on VPS: the Dockerfile runs
prisma generateat build andprisma migrate deployat startup. - Checkout redirect loops: missing one of the
PUBLIC_PATHSentries (e.g./api/auth/login) makes signup/login redirect forever.
Improvements
- Team seats with Stripe metered billing
- Usage dashboards with clickhouse-lite (or Timescale)
- 2FA with a TOTP library
- Email verification (Resend) before the first login
Conclusion
A subscription SaaS is a known recipe: Next.js + Prisma + Stripe webhooks + a signed-cookie middleware guard. The first 50 customers cost about $30/month to serve — and every file needed to run and ship it is above.