Loading…
Loading…
Learn how to build a multi-tenant SaaS application with Next.js using subdomain routing, tenant-aware middleware, and data isolation strategies.
Tags
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.
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.
Install the dependencies:
npm install prisma @prisma/client
npx prisma initThere are three main approaches to multi-tenancy, each with different trade-offs.
Shared Database with tenant_id (recommended for most SaaS):
tenant_id columnSeparate Schema per Tenant:
Separate Database per Tenant:
For this tutorial, we will use the shared database approach with tenant_id because it provides the best balance of simplicity and scalability.
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-tenantNext.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.
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.
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.
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.
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.
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.
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 multi-tenant architecture works in these layers:
*.yoursaas.com) points all subdomains to your applicationtenantId, enforced by the repository patterncustomDomain field with SSL certificate provisioningFor 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.
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.
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.
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
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.
Learn how to instrument a Node.js app with OpenTelemetry for traces, metrics, and logs, and build a practical observability setup for production debugging.
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.
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.