Multi-tenancy lets you serve multiple customers from a single codebase. Here is how to structure it in Next.js.
Multi-Tenancy Approaches
| Approach | Pros | Cons |
|---|---|---|
| Subdomain per tenant | Clean URLs, easy routing | DNS setup needed |
| Path-based (/org/acme) | Simpler setup | Messier URLs |
| Database-level isolation | Strongest isolation | More complex |
Step 1: Middleware for Subdomain Routing
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
export function middleware(req: NextRequest) {
const hostname = req.headers.get("host") || "";
const currentHost = hostname.replace(`.${process.env.NEXT_PUBLIC_ROOT_DOMAIN}`, "");
// Skip for root domain and special subdomains
if (
currentHost === process.env.NEXT_PUBLIC_ROOT_DOMAIN ||
currentHost === "www" ||
currentHost === "app"
) {
return NextResponse.next();
}
// Rewrite to tenant-specific routes
const url = req.nextUrl;
url.pathname = `/tenant/${currentHost}${url.pathname}`;
return NextResponse.rewrite(url);
}
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};
Step 2: Tenant Resolution
// lib/tenant.ts
import { db } from "@/db";
import { tenants } from "@/db/schema";
import { eq } from "drizzle-orm";
import { cache } from "react";
export interface Tenant {
id: string;
slug: string;
name: string;
domain?: string;
plan: "free" | "pro" | "enterprise";
settings: Record<string, unknown>;
}
export const getTenant = cache(async (slug: string): Promise<Tenant | null> => {
const [tenant] = await db
.select()
.from(tenants)
.where(eq(tenants.slug, slug))
.limit(1);
return tenant || null;
});
export const getTenantByDomain = cache(async (domain: string): Promise<Tenant | null> => {
const [tenant] = await db
.select()
.from(tenants)
.where(eq(tenants.domain, domain))
.limit(1);
return tenant || null;
});
Step 3: Tenant-Scoped Layout
// app/tenant/[slug]/layout.tsx
import { notFound } from "next/navigation";
import { getTenant } from "@/lib/tenant";
import { TenantProvider } from "@/components/TenantProvider";
export default async function TenantLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const tenant = await getTenant(slug);
if (!tenant) notFound();
return (
<TenantProvider tenant={tenant}>
<div
style={{
"--brand-color": tenant.settings.brandColor as string || "#3b82f6",
} as React.CSSProperties}
>
{children}
</div>
</TenantProvider>
);
}
Step 4: Tenant Context Provider
"use client";
import { createContext, useContext } from "react";
import type { Tenant } from "@/lib/tenant";
const TenantContext = createContext<Tenant | null>(null);
export function TenantProvider({
tenant,
children,
}: {
tenant: Tenant;
children: React.ReactNode;
}) {
return (
<TenantContext.Provider value={tenant}>{children}</TenantContext.Provider>
);
}
export function useTenant() {
const tenant = useContext(TenantContext);
if (!tenant) throw new Error("useTenant must be used within TenantProvider");
return tenant;
}
Step 5: Database Schema with Tenant Isolation
// db/schema.ts
import { pgTable, text, timestamp, uuid, index } from "drizzle-orm/pg-core";
export const tenants = pgTable("tenants", {
id: uuid("id").defaultRandom().primaryKey(),
slug: text("slug").notNull().unique(),
name: text("name").notNull(),
domain: text("domain").unique(),
plan: text("plan").$type<"free" | "pro" | "enterprise">().default("free"),
settings: text("settings").$type<string>().default("{}"),
createdAt: timestamp("created_at").defaultNow(),
});
// All data tables include tenantId
export const projects = pgTable(
"projects",
{
id: uuid("id").defaultRandom().primaryKey(),
tenantId: uuid("tenant_id")
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
name: text("name").notNull(),
createdAt: timestamp("created_at").defaultNow(),
},
(table) => ({
tenantIdx: index("projects_tenant_idx").on(table.tenantId),
})
);
export const members = pgTable("members", {
id: uuid("id").defaultRandom().primaryKey(),
tenantId: uuid("tenant_id")
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
userId: text("user_id").notNull(),
role: text("role").$type<"owner" | "admin" | "member">().default("member"),
createdAt: timestamp("created_at").defaultNow(),
});
Step 6: Tenant-Scoped Queries
// lib/queries.ts
import { db } from "@/db";
import { projects, members } from "@/db/schema";
import { eq, and } from "drizzle-orm";
// Always filter by tenantId
export async function getProjects(tenantId: string) {
return db
.select()
.from(projects)
.where(eq(projects.tenantId, tenantId));
}
export async function getProject(tenantId: string, projectId: string) {
const [project] = await db
.select()
.from(projects)
.where(
and(
eq(projects.tenantId, tenantId),
eq(projects.id, projectId)
)
);
return project || null;
}
// Verify membership before any operation
export async function verifyMembership(tenantId: string, userId: string) {
const [member] = await db
.select()
.from(members)
.where(
and(
eq(members.tenantId, tenantId),
eq(members.userId, userId)
)
);
return member || null;
}
Step 7: Tenant-Aware API Routes
// app/api/tenant/[slug]/projects/route.ts
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { getTenant } from "@/lib/tenant";
import { verifyMembership, getProjects } from "@/lib/queries";
export async function GET(
req: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { slug } = await params;
const tenant = await getTenant(slug);
if (!tenant) {
return NextResponse.json({ error: "Tenant not found" }, { status: 404 });
}
const member = await verifyMembership(tenant.id, session.user.id);
if (!member) {
return NextResponse.json({ error: "Not a member" }, { status: 403 });
}
const projects = await getProjects(tenant.id);
return NextResponse.json(projects);
}
Need a Multi-Tenant SaaS Platform?
We build scalable SaaS applications with multi-tenancy, tenant isolation, and enterprise features. Contact us to discuss your project.