How to Build a SaaS Application with Next.js
Architecture, authentication, billing, multi-tenancy, and deployment patterns for building a real SaaS product with Next.js, Prisma, and Stripe.
Most "build a SaaS" tutorials are really "build a landing page with a login form." They skip the hard parts: multi-tenancy, role-based access, subscription billing, and the architectural decisions that determine whether your codebase survives past 10 customers.
This guide covers the actual architecture of a SaaS application built with Next.js. Not a step-by-step clone of a specific product, but the patterns and decisions you'll face regardless of what you're building. We'll use Next.js App Router, Prisma, PostgreSQL, and Stripe — the stack that most indie SaaS founders end up on because it works.
The Tech Stack Decision
Here's what you need and why:
| Layer | Choice | Why |
|---|---|---|
| Framework | Next.js (App Router) | Server components, API routes, middleware — one framework handles everything |
| Database | PostgreSQL | Relational data, strong consistency, Stripe needs transactional integrity |
| ORM | Prisma | Type-safe queries, migrations, great DX |
| Auth | NextAuth.js (Auth.js) | Session management, OAuth providers, database sessions |
| Payments | Stripe | Industry standard, webhook-driven, handles tax/invoicing |
| Hosting | Vercel or self-hosted | Zero-config for Next.js, or Docker on any VPS |
| Resend | Developer-friendly API, React email templates |
Project Structure
src/
app/
(marketing)/ # Landing page, pricing, blog
page.tsx
pricing/page.tsx
(auth)/ # Login, signup, forgot password
login/page.tsx
signup/page.tsx
(dashboard)/ # Authenticated app
layout.tsx # Auth check, sidebar, navigation
dashboard/page.tsx
settings/
page.tsx
billing/page.tsx
team/page.tsx
api/
webhooks/
stripe/route.ts
trpc/[trpc]/route.ts # Optional: tRPC for type-safe APIs
lib/
db.ts # Prisma client singleton
auth.ts # NextAuth config
stripe.ts # Stripe client + helpers
permissions.ts # RBAC logic
components/
ui/ # Generic UI components
dashboard/ # Dashboard-specific components
prisma/
schema.prisma
migrations/
Route groups (marketing), (auth), (dashboard) give you different layouts without affecting URLs. The marketing pages get a landing page layout. The dashboard pages get a sidebar layout with auth protection.
Database Schema
Here's the Prisma schema for multi-tenant SaaS with team support:
// prisma/schema.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?
passwordHash String?
avatarUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
memberships Membership[]
accounts Account[] // OAuth accounts
sessions Session[]
}
model Organization {
id String @id @default(cuid())
name String
slug String @unique
stripeCustomerId String? @unique
plan Plan @default(FREE)
planExpiresAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
memberships Membership[]
invitations Invitation[]
}
model Membership {
id String @id @default(cuid())
role Role @default(MEMBER)
userId String
organizationId String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
@@unique([userId, organizationId])
}
model Invitation {
id String @id @default(cuid())
email String
role Role @default(MEMBER)
token String @unique @default(cuid())
organizationId String
expiresAt DateTime
createdAt DateTime @default(now())
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
}
enum Role {
OWNER
ADMIN
MEMBER
}
enum Plan {
FREE
PRO
ENTERPRISE
}
Key decisions here:
- Organization, not User, holds the subscription. Users belong to organizations through memberships. This is the multi-tenant pattern.
- Slug for URLs.
app.yoursite.com/org-name/dashboardis cleaner than UUID-based URLs. - Roles on the membership, not the user. A user can be an admin in one org and a member in another.
Authentication
NextAuth.js with database sessions. JWT sessions are simpler but database sessions let you revoke access instantly and query active sessions.
// src/lib/auth.ts
import NextAuth from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter";
import Google from "next-auth/providers/google";
import GitHub from "next-auth/providers/github";
import Credentials from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";
import { db } from "./db";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(db),
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
GitHub({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
Credentials({
credentials: {
email: { type: "email" },
password: { type: "password" },
},
async authorize(credentials) {
const user = await db.user.findUnique({
where: { email: credentials.email as string },
});
if (!user?.passwordHash) return null;
const valid = await bcrypt.compare(
credentials.password as string,
user.passwordHash
);
return valid ? user : null;
},
}),
],
callbacks: {
async session({ session, user }) {
session.user.id = user.id;
return session;
},
},
});
Protecting Dashboard Routes
Middleware handles the auth gate for the entire dashboard:
// src/middleware.ts
import { auth } from "./lib/auth";
export default auth((req) => {
const isAuth = !!req.auth;
const isDashboard = req.nextUrl.pathname.startsWith("/dashboard");
const isAuthPage =
req.nextUrl.pathname.startsWith("/login") ||
req.nextUrl.pathname.startsWith("/signup");
if (isDashboard && !isAuth) {
return Response.redirect(new URL("/login", req.nextUrl));
}
if (isAuthPage && isAuth) {
return Response.redirect(new URL("/dashboard", req.nextUrl));
}
});
export const config = {
matcher: ["/dashboard/:path*", "/login", "/signup"],
};
Stripe Billing Integration
The billing flow has three parts: checkout, webhook processing, and plan enforcement.
Creating a Checkout Session
// src/lib/stripe.ts
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-12-18.acacia",
});
const PRICE_IDS = {
PRO_MONTHLY: process.env.STRIPE_PRO_MONTHLY_PRICE_ID!,
PRO_YEARLY: process.env.STRIPE_PRO_YEARLY_PRICE_ID!,
ENTERPRISE_MONTHLY: process.env.STRIPE_ENTERPRISE_MONTHLY_PRICE_ID!,
ENTERPRISE_YEARLY: process.env.STRIPE_ENTERPRISE_YEARLY_PRICE_ID!,
};
export async function createCheckoutSession(
organizationId: string,
priceKey: keyof typeof PRICE_IDS,
successUrl: string,
cancelUrl: string
) {
const org = await db.organization.findUniqueOrThrow({
where: { id: organizationId },
});
// Create or reuse Stripe customer
let customerId = org.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
metadata: { organizationId },
});
customerId = customer.id;
await db.organization.update({
where: { id: organizationId },
data: { stripeCustomerId: customerId },
});
}
return stripe.checkout.sessions.create({
customer: customerId,
mode: "subscription",
line_items: [{ price: PRICE_IDS[priceKey], quantity: 1 }],
success_url: successUrl,
cancel_url: cancelUrl,
metadata: { organizationId },
});
}
The Webhook Handler
This is the most critical piece. Stripe communicates subscription changes through webhooks. Your app must handle these reliably:
// src/app/api/webhooks/stripe/route.ts
import { headers } from "next/headers";
import { stripe } from "@/lib/stripe";
import { db } from "@/lib/db";
export async function POST(req: Request) {
const body = await req.text();
const signature = headers().get("stripe-signature")!;
let event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
console.error("Webhook signature verification failed:", err);
return new Response("Invalid signature", { status: 400 });
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object;
const orgId = session.metadata?.organizationId;
if (orgId) {
await db.organization.update({
where: { id: orgId },
data: { plan: "PRO" }, // Determine from price
});
}
break;
}
case "customer.subscription.updated": {
const subscription = event.data.object;
const customer = await stripe.customers.retrieve(
subscription.customer as string
);
const orgId = (customer as any).metadata?.organizationId;
if (orgId) {
const isActive = ["active", "trialing"].includes(subscription.status);
await db.organization.update({
where: { id: orgId },
data: {
plan: isActive ? "PRO" : "FREE",
planExpiresAt: new Date(subscription.current_period_end * 1000),
},
});
}
break;
}
case "customer.subscription.deleted": {
const subscription = event.data.object;
const customer = await stripe.customers.retrieve(
subscription.customer as string
);
const orgId = (customer as any).metadata?.organizationId;
if (orgId) {
await db.organization.update({
where: { id: orgId },
data: { plan: "FREE", planExpiresAt: null },
});
}
break;
}
}
return new Response("OK", { status: 200 });
}
Plan Enforcement
Create a utility to check permissions based on plan:
// src/lib/permissions.ts
type PlanLimits = {
maxMembers: number;
maxProjects: number;
features: string[];
};
const PLAN_LIMITS: Record<string, PlanLimits> = {
FREE: {
maxMembers: 2,
maxProjects: 3,
features: ["basic_analytics"],
},
PRO: {
maxMembers: 10,
maxProjects: 50,
features: ["basic_analytics", "advanced_analytics", "api_access", "exports"],
},
ENTERPRISE: {
maxMembers: Infinity,
maxProjects: Infinity,
features: ["basic_analytics", "advanced_analytics", "api_access", "exports", "sso", "audit_log"],
},
};
export function getPlanLimits(plan: string): PlanLimits {
return PLAN_LIMITS[plan] || PLAN_LIMITS.FREE;
}
export function canUseFeature(plan: string, feature: string): boolean {
return getPlanLimits(plan).features.includes(feature);
}
Multi-Tenancy via Middleware
Every dashboard request resolves the current organization from the URL:
// src/lib/org-context.ts
import { cache } from "react";
import { auth } from "./auth";
import { db } from "./db";
export const getCurrentOrg = cache(async (slug: string) => {
const session = await auth();
if (!session?.user?.id) return null;
const membership = await db.membership.findFirst({
where: {
userId: session.user.id,
organization: { slug },
},
include: { organization: true },
});
return membership
? { organization: membership.organization, role: membership.role }
: null;
});
Using React's cache() ensures the database query runs at most once per request, even if multiple server components call getCurrentOrg.
Deployment Checklist
Before you ship:
| Category | Item | Status |
|---|---|---|
| Security | Environment variables in hosting provider, not .env in production | Required |
| Security | Stripe webhook signature verification | Required |
| Security | CSRF protection on mutations | Required |
| Security | Rate limiting on auth endpoints | Required |
| Database | Run migrations in CI/CD pipeline | Required |
| Database | Connection pooling (PgBouncer or Prisma Accelerate) | Recommended |
| Monitoring | Error tracking (Sentry) | Recommended |
| Monitoring | Uptime monitoring | Recommended |
| Transactional email service configured | Required | |
| Legal | Privacy policy and terms of service pages | Required |
Mistakes That Kill SaaS Projects
Over-engineering on day one. You don't need Kubernetes, a message queue, or microservices. Build a monolith. Extract services when (if) you actually hit scale problems. Most SaaS products never get enough traffic to justify distributed systems. Building features nobody asked for. Ship the minimum viable product. Talk to users. Build what they need, not what you think is cool. Ignoring billing edge cases. What happens when a payment fails? When someone downgrades mid-cycle? When a trial expires? Stripe handles a lot of this, but your webhook handler needs to cover every subscription state transition. No onboarding flow. The moment between signup and "aha, this is useful" is the most critical. Guide users through setup. Show them value in under two minutes.Moving Forward
The architecture above handles the core SaaS concerns: authentication, authorization, billing, multi-tenancy, and team management. Everything else — your actual product features — builds on top of this foundation.
You can explore full-stack project patterns and interactive exercises on CodeUp. The patterns here translate directly to any SaaS product, whether you're building a project management tool, a CRM, or a developer platform.
Start with the smallest possible version that someone would pay for. Then iterate.