
Next.js Building multi-tenant apps
If you check the official Next.js docs page for "Building multi-tenant apps," you'll find something unusual for this documentation set: it's one sentence long. It points to a reference example — Vercel's Platforms Starter Kit — and stops. That's not an oversight; multi-tenancy isn't really a Next.js feature the way Route Handlers or Image Optimization are. It's an architecture pattern that Next.js happens to support well, built from primitives (Proxy, dynamic rendering, the caching system) that already exist for other reasons.
That means this article has to do something the docs page doesn't: actually walk through how the pattern works, because "go look at an example repo" isn't a complete answer if you're trying to understand the decisions behind it.
What "multi-tenant" actually means here
A multi-tenant application serves multiple distinct customers — tenants — from one running application and one codebase, where each tenant experiences something that feels like their own dedicated instance: their own subdomain or custom domain, their own branding, their own data, sometimes their own feature set. Classic examples are SaaS products like a website builder (each customer's site lives at customer.yourplatform.com or their own domain), a headless CMS platform, or a multi-brand e-commerce system.
The alternative — provisioning a separate deployment per customer — works too, and is simpler in some ways, but it stops scaling once you have more than a handful of tenants: every deploy becomes N deploys, every config change becomes N config changes, and your infrastructure bill grows linearly with signups regardless of how much traffic each tenant actually generates. Multi-tenancy trades that operational overhead for architectural complexity concentrated in one place, which is usually the right trade once you're past a few dozen tenants.
The core problem: routing a request to the right tenant
Everything else in a multi-tenant Next.js app flows from answering one question correctly: given an incoming request, which tenant does it belong to?
There are three common ways to identify a tenant from a request, each with different trade-offs:
Subdomain-based (acme.yourapp.com, globex.yourapp.com) is the most common pattern for B2B SaaS. It's simple to reason about, plays nicely with wildcard DNS and wildcard TLS certificates, and keeps each tenant's URLs visually distinct without needing a path prefix.
Path-based (yourapp.com/acme, yourapp.com/globex) avoids any DNS or certificate complexity — everything lives under one domain — at the cost of URLs that look less like "this is Acme's own space" and more like "this is a page within your app about Acme."
Custom domain (app.acmecorp.com pointing at your infrastructure) is the most demanding option operationally — it requires per-tenant TLS certificate provisioning and DNS verification — but it's what customers actually want once they're paying you real money, because it makes your platform invisible: their users never see your brand at all.
Most serious multi-tenant products end up supporting subdomains as the default onboarding experience and custom domains as a premium upgrade, which means your routing logic needs to handle both from day one even if custom domains ship later.
Resolving the tenant with Proxy
This is where Next.js's own primitives actually enter the picture, and it's proxy.js — the file that replaced Middleware in recent Next.js versions — doing the heavy lifting. Proxy runs before a request reaches your route handling, which makes it exactly the right place to inspect the incoming hostname and attach tenant context.
// proxy.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const ROOT_DOMAIN = "yourapp.com";
export function proxy(request: NextRequest) {
const hostname = request.headers.get("host") || "";
const subdomain = hostname
.replace(`.${ROOT_DOMAIN}`, "")
.replace(ROOT_DOMAIN, "");
// Root domain itself (marketing site, signup flow) — no tenant
if (!subdomain || subdomain === "www") {
return NextResponse.next();
}
// Rewrite internally to a tenant-scoped route segment,
// without changing what the browser's URL bar shows
const url = request.nextUrl.clone();
url.pathname = `/tenants/${subdomain}${url.pathname}`;
return NextResponse.rewrite(url);
}
export const config = {
matcher: ["/((?!_next|api|static).*)"],
};
The rewrite here is doing something specific and worth understanding: it doesn't redirect the browser anywhere — the URL the visitor sees stays acme.yourapp.com/dashboard. Internally, Next.js resolves that request against app/tenants/[tenant]/dashboard/page.tsx instead. The tenant identity becomes part of your file-system routing without ever leaking into the visible URL.
app/
tenants/
[tenant]/
layout.tsx ← resolves tenant, fetches branding/config
dashboard/
page.tsx
settings/
page.tsx
Loading tenant context once, at the layout
With the rewrite in place, [tenant] becomes a dynamic segment available to every page beneath it. The natural place to resolve "what does this tenant look like" — their branding, their plan, their feature flags — is the layout for that segment, so every nested page gets it without re-fetching:
// app/tenants/[tenant]/layout.tsx
import { notFound } from "next/navigation";
async function getTenant(slug: string) {
"use cache";
const tenant = await db.tenant.findUnique({ where: { slug } });
return tenant;
}
export default async function TenantLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ tenant: string }>;
}) {
const { tenant: slug } = await params;
const tenant = await getTenant(slug);
if (!tenant) {
notFound();
}
return (
<div style={{ "--brand-color": tenant.brandColor } as React.CSSProperties}>
{children}
</div>
);
}
Caching this lookup matters more than it might seem. Every single request for every tenant hits this function, so an uncached database round-trip here becomes your app's baseline per-request latency floor, multiplied across every tenant and every route. Tag the cache by tenant slug so that updating one tenant's settings doesn't require invalidating everyone else's:
async function getTenant(slug: string) {
"use cache";
cacheTag(`tenant-${slug}`);
cacheLife("hours");
const tenant = await db.tenant.findUnique({ where: { slug } });
return tenant;
}
Now a tenant updating their branding just calls revalidateTag(\tenant-$`)` from a Server Action, and only that tenant's cached layout data gets invalidated — every other tenant's request keeps serving from cache, unaffected.
Data isolation: the part you cannot get wrong
Routing gets a customer to the right page. It says nothing about whether that page shows the right data — and this is where multi-tenant bugs turn into security incidents rather than cosmetic issues. If Acme's dashboard query doesn't filter by tenant, a bug doesn't just show a broken layout; it shows another company's customer data.
The database-level strategies, roughly from simplest to most isolated:
Shared tables with a tenantId column. Every query filters by tenant ID. Cheapest to operate, easiest to add a new tenant to (just a row), and the highest-risk option if a single query anywhere in the codebase forgets the filter. This is the default choice for most SaaS products, and it demands discipline: a query helper or ORM middleware that injects the tenant filter automatically is worth building early, because "remember to add WHERE tenant_id = ? to every query, forever, across every contributor" is not a real safety guarantee.
Schema-per-tenant (same database, separate PostgreSQL schemas per tenant) buys stronger isolation — a forgotten WHERE clause can't leak across schemas — at the cost of migration complexity, since every schema change now needs to run against every tenant's schema.
Database-per-tenant is the strongest isolation available short of separate infrastructure entirely, reserved for enterprise tiers where a customer is paying specifically for guaranteed data separation, or where compliance requirements (data residency, in particular) demand it.
Whichever you choose, the enforcement point should live as close to the data-access layer as your stack allows — not scattered across individual page components trusting each other to remember. If you're on the shared-table model, wrap your ORM client so tenant scoping isn't optional:
// lib/db.ts
export function forTenant(tenantId: string) {
return {
posts: {
findMany: () => db.post.findMany({ where: { tenantId } }),
// every method here is pre-scoped; there's no "forgot the filter" path
},
};
}
Server Actions and origin checks
Server Actions execute as POST requests under the hood, and Next.js validates the request's origin against what it expects by default — which matters more in multi-tenant setups than single-tenant ones, because you now have many valid origins (every tenant subdomain, plus any custom domains) rather than one. If your tenant domains are added dynamically (a customer connects app.theirdomain.com after signing up), you need allowedOrigins in your Server Actions config to reflect that dynamic set, or legitimate tenant traffic starts getting rejected as a mismatched origin.
What actually needs per-tenant customization
Not everything needs to vary per tenant, and being disciplined about what does keeps the system maintainable:
Branding — logo, color scheme, custom CSS — is the most common ask and the safest to support broadly, since it's purely presentational.
Feature flags — which parts of the product a given tenant's plan includes — matter for gating premium functionality, and pair naturally with the same tenant-context layout pattern above.
Custom domains are the heaviest lift: verifying DNS ownership, provisioning TLS certificates (Vercel and most modern hosts automate this via ACME/Let's Encrypt behind the scenes), and routing requests that arrive on a domain your app doesn't directly control.
What you generally should not customize per tenant, at least not without strong justification, is the application's core logic and component tree. A multi-tenant app that lets each tenant have meaningfully different behavior (not just branding) quickly turns into maintaining N different products under one name — which defeats the entire economic argument for building this way in the first place.
Why the official example is worth actually opening
Given how thin the official docs page is, the Platforms Starter Kit it links to is doing more work than a typical "example app" — it's effectively the documentation. It demonstrates the subdomain-rewriting Proxy pattern, per-tenant data fetching, and a working admin flow for provisioning new tenants, wired together in a way that's genuinely worth reading start to finish rather than skimming, precisely because the prose documentation for this topic doesn't exist beyond a single sentence.
Key Takeaways
| Decision | Common default | When to deviate |
|---|---|---|
| Tenant identification | Subdomain | Path-based for simpler DNS/cert needs; custom domain as a premium tier |
| Tenant resolution | Proxy rewrite to /tenants/[tenant]/... | — |
| Tenant context loading | Cached fetch in the segment layout, tagged per tenant | — |
| Data isolation | Shared tables + enforced tenantId filter | Schema- or database-per-tenant for compliance/enterprise needs |
| Server Actions | Configure allowedOrigins for all valid tenant domains | — |
| What to vary per tenant | Branding, feature flags, custom domain | Avoid varying core application logic per tenant |
Multi-tenancy in Next.js isn't a feature you enable — it's a set of decisions (how you identify a tenant, how you isolate their data, how much you let them customize) that you make once, early, using ordinary framework primitives that already exist for other reasons. Get the routing and data-isolation layers right at the start, because retrofitting tenant isolation into an app that wasn't built with it in mind is a considerably harder project than building it in from day one.


