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:

  1. Stripe Dashboard → Test modeProductsAdd product (“Pro”, $9/month recurring) → Create.
  2. Open the new product → API ID / Pricing → copy the Price ID (price_1...).
  3. 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 email parameter is actually used now — no hardcoded address.
  • metadata.userId is how the webhook maps the Stripe event back to the user. Do not rely on session.customer_email; if the customer already exists in Stripe, it can be null.
  • success_url/cancel_url come from the request origin, so checkout works on localhost and 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_dump backup + restore drill
  • Stripe test mode → live mode key swap
  • invoice.paid / invoice.payment_failed webhook handlers for dunning

Common problems

  • Webhook 400s: missing STRIPE_WEBHOOK_SECRET or a payload replayed with the wrong secret — test with stripe listen.
  • Downgrade not applied: customer.subscription.deleted only fires on cancellation; add invoice.payment_failed → set a past_due flag for failed payments.
  • Prisma + Next build on VPS: the Dockerfile runs prisma generate at build and prisma migrate deploy at startup.
  • Checkout redirect loops: missing one of the PUBLIC_PATHS entries (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.

Related guides