March 26, 20269 min read

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.

saas next.js tutorial startup full-stack
Ad 336x280

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:

LayerChoiceWhy
FrameworkNext.js (App Router)Server components, API routes, middleware — one framework handles everything
DatabasePostgreSQLRelational data, strong consistency, Stripe needs transactional integrity
ORMPrismaType-safe queries, migrations, great DX
AuthNextAuth.js (Auth.js)Session management, OAuth providers, database sessions
PaymentsStripeIndustry standard, webhook-driven, handles tax/invoicing
HostingVercel or self-hostedZero-config for Next.js, or Docker on any VPS
EmailResendDeveloper-friendly API, React email templates
You don't need Redis on day one. You don't need a message queue. You don't need microservices. Start with a monolith and extract later when you have actual scaling problems, not imagined ones.

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/dashboard is 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:

CategoryItemStatus
SecurityEnvironment variables in hosting provider, not .env in productionRequired
SecurityStripe webhook signature verificationRequired
SecurityCSRF protection on mutationsRequired
SecurityRate limiting on auth endpointsRequired
DatabaseRun migrations in CI/CD pipelineRequired
DatabaseConnection pooling (PgBouncer or Prisma Accelerate)Recommended
MonitoringError tracking (Sentry)Recommended
MonitoringUptime monitoringRecommended
EmailTransactional email service configuredRequired
LegalPrivacy policy and terms of service pagesRequired

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.

Ad 728x90