Build a Multi-Tenant SaaS App with Next.js
Learn how to build a multi-tenant SaaS application with Next.js using subdomain routing, tenant-aware middleware, and data isolation strategies.
Tags
Build a Multi-Tenant SaaS App with Next.js
In this tutorial, you will build the foundational architecture for a multi-tenant SaaS application using Next.js. You will implement tenant resolution from subdomains, create middleware that injects tenant context into every request, design a database schema with proper tenant isolation, and build a data access layer that prevents cross-tenant data leaks.
Multi-tenancy is the architecture pattern that allows a single application instance to serve multiple customers (tenants), each with their own isolated data, configuration, and often their own subdomain. It is the backbone of every SaaS product from Slack to Notion to Linear.
TL;DR
Resolve the tenant from the subdomain in Next.js middleware, store the tenant ID in a request header, scope all database queries with a tenant_id foreign key using a repository pattern, and use Prisma middleware to enforce tenant isolation automatically. This shared-database approach scales well for most SaaS applications.
Prerequisites
- ›Next.js 14+ with the App Router
- ›Prisma (or another TypeScript ORM)
- ›Basic understanding of DNS and subdomains
- ›A PostgreSQL database
Install the dependencies:
npm install prisma @prisma/client
npx prisma initStep 1: Choose Your Tenant Isolation Strategy
There are three main approaches to multi-tenancy, each with different trade-offs.
Shared Database with tenant_id (recommended for most SaaS):
- ›All tenants share the same database and tables
- ›Every table has a
tenant_idcolumn - ›Simplest to manage, migrate, and scale
- ›Requires careful query scoping to prevent data leaks
Separate Schema per Tenant:
- ›Each tenant gets their own database schema within one database
- ›Better isolation than shared tables
- ›Schema migrations must be applied to all schemas
- ›Moderate complexity
Separate Database per Tenant:
- ›Each tenant gets a completely separate database
- ›Maximum isolation and independent scaling
- ›Highest operational complexity
- ›Reserved for regulated industries or extreme scale requirements
For this tutorial, we will use the shared database approach with tenant_id because it provides the best balance of simplicity and scalability.
Step 2: Design the Database Schema
Define your Prisma schema with tenant isolation built in from the start.
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Tenant {
id String @id @default(cuid())
name String
slug String @unique // Used as subdomain
customDomain String? @unique
plan String @default("free")
settings Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
projects Project[]
@@index([slug])
@@index([customDomain])
}
model User {
id String @id @default(cuid())
email String
name String
role String @default("member") // "owner", "admin", "member"
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
createdAt DateTime @default(now())
projects Project[]
@@unique([email, tenantId]) // Same email can exist in different tenants
@@index([tenantId])
}
model Project {
id String @id @default(cuid())
name String
description String?
status String @default("active")
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
createdBy String
creator User @relation(fields: [createdBy], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tenantId])
@@index([tenantId, status])
}Every table that contains tenant-specific data includes a tenantId column with an index. The @@unique([email, tenantId]) constraint on the User model allows the same email address to exist in different tenants, which is important because users may belong to multiple organizations.
Run the migration:
npx prisma migrate dev --name init-multi-tenantStep 3: Build Tenant Resolution Middleware
Next.js middleware runs on every request before it reaches your pages or API routes. Use it to extract the tenant from the subdomain.
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
// Subdomains that are not tenant slugs
const RESERVED_SUBDOMAINS = new Set(["www", "app", "api", "admin"]);
export function middleware(request: NextRequest) {
const hostname = request.headers.get("host") || "";
const url = request.nextUrl.clone();
// Extract subdomain
// In production: tenant.yoursaas.com
// In development: tenant.localhost:3000
const baseDomain = process.env.NEXT_PUBLIC_BASE_DOMAIN || "localhost:3000";
const subdomain = hostname.replace(`.${baseDomain}`, "").split(".")[0];
// Check if this is a tenant subdomain
const isTenantSubdomain =
subdomain &&
subdomain !== baseDomain.split(":")[0] &&
!RESERVED_SUBDOMAINS.has(subdomain);
if (isTenantSubdomain) {
// Pass tenant slug to the application via headers
const response = NextResponse.next();
response.headers.set("x-tenant-slug", subdomain);
return response;
}
// If no subdomain, serve the marketing site or login page
return NextResponse.next();
}
export const config = {
matcher: [
// Match all paths except static files and Next.js internals
"/((?!_next/static|_next/image|favicon.ico).*)",
],
};The middleware extracts the subdomain from the hostname, checks that it is not a reserved subdomain like www or api, and forwards the tenant slug as a custom header. This approach is stateless and does not require a database lookup in middleware, keeping latency minimal.
Step 4: Create the Tenant Context
Build a server-side utility that resolves the full tenant from the slug and makes it available throughout your request lifecycle.
// lib/tenant.ts
import { headers } from "next/headers";
import { prisma } from "@/lib/prisma";
import { cache } from "react";
import { notFound } from "next/navigation";
export interface TenantContext {
id: string;
name: string;
slug: string;
plan: string;
settings: Record<string, unknown>;
}
// cache() ensures this only runs once per request
export const getCurrentTenant = cache(async (): Promise<TenantContext> => {
const headersList = await headers();
const slug = headersList.get("x-tenant-slug");
if (!slug) {
notFound();
}
const tenant = await prisma.tenant.findUnique({
where: { slug },
select: {
id: true,
name: true,
slug: true,
plan: true,
settings: true,
},
});
if (!tenant) {
notFound();
}
return {
...tenant,
settings: tenant.settings as Record<string, unknown>,
};
});The cache() wrapper from React ensures that even if multiple components call getCurrentTenant() during the same request, the database query only executes once. This is important because Server Components in the App Router can independently call this function without worrying about duplicate queries.
Step 5: Build the Tenant-Scoped Data Access Layer
This is the most critical piece. Create a repository pattern that automatically scopes every query to the current tenant.
// lib/repositories/project-repository.ts
import { prisma } from "@/lib/prisma";
import { getCurrentTenant } from "@/lib/tenant";
export async function getProjects() {
const tenant = await getCurrentTenant();
return prisma.project.findMany({
where: { tenantId: tenant.id },
orderBy: { createdAt: "desc" },
});
}
export async function getProjectById(projectId: string) {
const tenant = await getCurrentTenant();
const project = await prisma.project.findFirst({
where: {
id: projectId,
tenantId: tenant.id, // Always scope by tenant
},
});
return project;
}
export async function createProject(data: {
name: string;
description?: string;
createdBy: string;
}) {
const tenant = await getCurrentTenant();
return prisma.project.create({
data: {
...data,
tenantId: tenant.id,
},
});
}
export async function updateProject(
projectId: string,
data: { name?: string; description?: string; status?: string }
) {
const tenant = await getCurrentTenant();
// First verify the project belongs to this tenant
const existing = await prisma.project.findFirst({
where: { id: projectId, tenantId: tenant.id },
});
if (!existing) {
throw new Error("Project not found");
}
return prisma.project.update({
where: { id: projectId },
data,
});
}
export async function deleteProject(projectId: string) {
const tenant = await getCurrentTenant();
// Verify ownership before deletion
const existing = await prisma.project.findFirst({
where: { id: projectId, tenantId: tenant.id },
});
if (!existing) {
throw new Error("Project not found");
}
return prisma.project.delete({
where: { id: projectId },
});
}Notice that every function includes tenantId: tenant.id in the where clause. This prevents a user from accessing another tenant's data by guessing project IDs. The findFirst pattern with both id and tenantId is safer than findUnique with just id because it ensures tenant scoping even if someone tries to pass a valid project ID from another tenant.
Step 6: Add Prisma Middleware for Automatic Scoping
For an additional safety layer, add Prisma middleware that automatically injects tenant scoping. This catches any query that accidentally omits the tenant filter.
// lib/prisma.ts
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" ? ["warn", "error"] : ["error"],
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
// Soft-delete middleware or query logging can be added here
prisma.$use(async (params, next) => {
const tenantScopedModels = ["Project", "User"];
if (
params.model &&
tenantScopedModels.includes(params.model) &&
["findMany", "findFirst", "count", "aggregate"].includes(params.action)
) {
// Log a warning if tenantId is missing from the query
const where = params.args?.where;
if (where && !where.tenantId) {
console.warn(
`WARNING: Query on ${params.model}.${params.action} missing tenantId filter`
);
}
}
return next(params);
});This middleware logs a warning whenever a query on a tenant-scoped model does not include a tenantId filter. In production, you could escalate this to throw an error to prevent any unscoped query from executing.
Step 7: Build Tenant-Aware Pages
Use the tenant context in your Server Components to display tenant-specific data.
// app/dashboard/page.tsx
import { getCurrentTenant } from "@/lib/tenant";
import { getProjects } from "@/lib/repositories/project-repository";
export default async function DashboardPage() {
const tenant = await getCurrentTenant();
const projects = await getProjects();
return (
<div className="min-h-screen bg-black p-8">
<div className="mx-auto max-w-6xl">
<div className="mb-8">
<h1 className="text-2xl font-bold text-white">{tenant.name}</h1>
<p className="text-gray-400">
Plan: {tenant.plan} | {projects.length} projects
</p>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => (
<div
key={project.id}
className="rounded-xl border border-gray-800 bg-gray-900 p-6"
>
<h3 className="font-semibold text-white">{project.name}</h3>
<p className="mt-2 text-sm text-gray-400">
{project.description || "No description"}
</p>
<span className="mt-3 inline-block rounded-full bg-gray-800 px-3 py-1 text-xs text-gray-300">
{project.status}
</span>
</div>
))}
</div>
</div>
</div>
);
}Because getCurrentTenant() is cached per request and getProjects() always scopes by tenant, this component is guaranteed to only show data belonging to the current tenant. No additional filtering is needed at the component level.
Step 8: Handle Tenant Onboarding
Create an API route for registering new tenants and provisioning their workspace.
// app/api/tenants/route.ts
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { z } from "zod";
const createTenantSchema = z.object({
name: z.string().min(2).max(50),
slug: z
.string()
.min(3)
.max(30)
.regex(/^[a-z0-9-]+$/, "Slug must contain only lowercase letters, numbers, and hyphens"),
ownerEmail: z.string().email(),
ownerName: z.string().min(2),
});
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const data = createTenantSchema.parse(body);
// Check if slug is already taken
const existing = await prisma.tenant.findUnique({
where: { slug: data.slug },
});
if (existing) {
return NextResponse.json(
{ error: "This workspace URL is already taken" },
{ status: 409 }
);
}
// Create tenant and owner in a transaction
const result = await prisma.$transaction(async (tx) => {
const tenant = await tx.tenant.create({
data: {
name: data.name,
slug: data.slug,
plan: "free",
settings: {
features: { analytics: false, api: false, customBranding: false },
limits: { maxUsers: 5, maxProjects: 10 },
},
},
});
const owner = await tx.user.create({
data: {
email: data.ownerEmail,
name: data.ownerName,
role: "owner",
tenantId: tenant.id,
},
});
return { tenant, owner };
});
return NextResponse.json(
{
tenant: { id: result.tenant.id, slug: result.tenant.slug },
redirectUrl: `https://${result.tenant.slug}.${process.env.NEXT_PUBLIC_BASE_DOMAIN}`,
},
{ status: 201 }
);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: "Validation failed", details: error.errors },
{ status: 400 }
);
}
console.error("Tenant creation failed:", error);
return NextResponse.json(
{ error: "Failed to create workspace" },
{ status: 500 }
);
}
}The transaction ensures that either both the tenant and the owner user are created, or neither is. The settings JSON field stores plan-specific feature flags and limits that control what each tenant can access.
Step 9: Configure Local Development
To test subdomains locally, update your system's hosts file and Next.js configuration.
# Add to /etc/hosts (Mac/Linux) or C:\Windows\System32\drivers\etc\hosts (Windows)
127.0.0.1 acme.localhost
127.0.0.1 globex.localhost// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Allow subdomain access in development
async rewrites() {
return {
beforeFiles: [],
afterFiles: [],
fallback: [],
};
},
};
export default nextConfig;Add the base domain to your environment:
# .env.local
NEXT_PUBLIC_BASE_DOMAIN=localhost:3000
DATABASE_URL=postgresql://user:password@localhost:5432/saas_dbNow you can access http://acme.localhost:3000 and http://globex.localhost:3000 as separate tenants during development.
The Complete Architecture
The multi-tenant architecture works in these layers:
- ›DNS Layer: Wildcard DNS record (
*.yoursaas.com) points all subdomains to your application - ›Middleware Layer: Extracts the tenant slug from the subdomain and passes it via headers
- ›Tenant Resolution Layer: Looks up the tenant from the database, cached per request
- ›Data Access Layer: Every query is scoped by
tenantId, enforced by the repository pattern - ›Safety Layer: Prisma middleware warns about unscoped queries as a last line of defense
Next Steps
- ›Add authentication with a library like Better Auth that supports organization-based multi-tenancy
- ›Implement role-based access control (RBAC) with tenant-specific roles and permissions
- ›Add custom domain support using the
customDomainfield with SSL certificate provisioning - ›Build a tenant admin panel for managing users, billing, and settings
- ›Implement rate limiting per tenant to prevent abuse
- ›Add tenant-specific theming using the settings JSON field to store brand colors and logos
FAQ
Should I use a separate database per tenant or a shared database?
For most SaaS applications, a shared database with a tenant_id column on every table is the right choice. It is simpler to manage migrations, perform backups, and scale horizontally. Use separate databases only when regulatory requirements demand physical data isolation, or when individual tenants have extremely large datasets that need independent scaling.
How do I handle custom domains for tenants?
Use Next.js middleware to check the request hostname against a database table of custom domain mappings. When a custom domain is detected, resolve it to the corresponding tenant. For SSL, use a wildcard certificate for your primary domain and an automated certificate provisioning service like Let's Encrypt with DNS-01 challenges for custom domains.
How do I prevent data leaks between tenants?
Enforce tenant scoping at the data access layer using a repository pattern, not at the API route level. Every database query must include a tenantId filter. Add Prisma middleware that logs or blocks unscoped queries as a safety net. For maximum security, enable PostgreSQL Row Level Security (RLS) policies that enforce tenant isolation at the database engine level.
How do I handle tenant-specific feature flags?
Store feature flags in a settings JSON column on the Tenant model. Load the tenant settings during resolution in middleware and make them available throughout your application. This approach lets each tenant have different features enabled based on their subscription plan without deploying separate code for each tenant.
Collaboration
Need help with a project?
Let's Build It
I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI — let's bring your vision to life.
Related Articles
How to Add Observability to a Node.js App with OpenTelemetry
Learn how to instrument a Node.js app with OpenTelemetry for traces, metrics, and logs, and build a practical observability setup for production debugging.
How to Build a Backend-for-Frontend (BFF) with Next.js and Node.js
A practical guide to building a Backend-for-Frontend with Next.js and Node.js for API aggregation, auth handling, caching, and frontend-specific data shaping.
How I Structure CI/CD for Next.js, Docker, and GitHub Actions
A practical CI/CD blueprint for Next.js apps using Docker and GitHub Actions, including testing, image builds, deployment stages, cache strategy, and release safety.