Introduction
Multi-tenancy is one of those architectural patterns that sounds intimidating at first, but it's actually the backbone of nearly every SaaS product you've ever used. At its core, it just means a single application instance serves multiple customers — called tenants — each with their own isolated data, configuration, and sometimes custom branding. Think Notion, Slack, or any project management tool where each company gets its own workspace.
Here's the thing: Next.js App Router is genuinely well-suited for this. Server Components let you resolve tenant context on the server without shipping tenant-detection logic to the client. The proxy layer (or middleware in earlier versions) intercepts every request at the edge, so subdomain and custom domain resolution happens before your application code even runs. And Incremental Static Regeneration (ISR) means you can statically generate tenant-specific pages while keeping them fresh — a massive performance win for SaaS at scale.
This guide walks you through building a production-ready multi-tenant Next.js application: choosing a routing strategy, resolving tenants in proxy.ts, scoping data access by tenant, handling custom domains, implementing tenant-aware authentication, and deploying to Vercel with wildcard domains. Every section includes working code you can adapt for your own project.
Choosing a Multi-Tenant Routing Strategy
Before writing any code, you need to decide how tenants will be identified from incoming requests. There are three main approaches, and you can actually combine them.
Path-Based Routing
With path-based routing, the tenant identifier lives in the URL path: yourdomain.com/acme/dashboard. This is the simplest approach because it requires zero DNS configuration and works out of the box in development. It's great for MVPs.
Subdomain-Based Routing
Subdomain-based routing uses the host header to identify tenants: acme.yourdomain.com/dashboard. This gives you cleaner URLs and stronger visual isolation between tenants. The trade-off? It requires wildcard DNS configuration and a slightly more complex local dev setup.
Custom Domain Routing
Custom domain routing lets tenants bring their own domains: app.acmecorp.com/dashboard. This is the premium feature that enterprise customers pretty much expect at this point. It requires a domain-to-tenant mapping in your database and SSL certificate provisioning for each custom domain.
Comparison of Routing Strategies
| Feature | Path-Based | Subdomain | Custom Domain |
|---|---|---|---|
| Setup complexity | Low | Medium | High |
| DNS configuration | None | Wildcard A/CNAME | Per-tenant CNAME |
| URL cleanliness | Moderate | Clean | Best |
| Tenant isolation feel | Low | High | Highest |
| Local dev experience | Easy | Requires *.localhost | Requires /etc/hosts |
| SSL handling | Single cert | Wildcard cert | Per-domain cert |
| Cookie isolation | Shared domain | Subdomain-scoped | Fully isolated |
For most SaaS products, I'd recommend starting with subdomain-based routing and adding custom domain support later as a premium feature. Path-based routing works well for internal tools or MVPs where DNS configuration is a barrier. This guide focuses primarily on subdomain and custom domain routing since those cover the most production-relevant scenarios.
Project Setup and Folder Structure
A well-organized folder structure makes or breaks multi-tenant applications. Here's the recommended layout for a subdomain-based multi-tenant Next.js App Router project:
my-saas-app/
├── app/
│ ├── (marketing)/ # Public marketing site (yourdomain.com)
│ │ ├── page.tsx
│ │ ├── pricing/
│ │ └── layout.tsx
│ ├── (tenant)/ # Tenant-scoped routes (*.yourdomain.com)
│ │ ├── layout.tsx # Tenant layout with branding
│ │ ├── page.tsx # Tenant dashboard
│ │ ├── settings/
│ │ │ └── page.tsx
│ │ └── projects/
│ │ ├── page.tsx
│ │ └── [projectId]/
│ │ └── page.tsx
│ ├── api/
│ │ └── tenants/
│ │ └── route.ts
│ └── layout.tsx # Root layout
├── lib/
│ ├── tenant.ts # Tenant resolution utilities
│ ├── db/
│ │ ├── schema.ts # Drizzle ORM schema
│ │ └── index.ts # Database connection
│ └── auth/
│ └── config.ts # Auth.js configuration
├── proxy.ts # Tenant resolution at the edge
└── next.config.ts
The key insight here is the use of route groups: (marketing) for public-facing pages served on the root domain, and (tenant) for all tenant-scoped pages. The proxy layer determines which route group to serve based on the hostname. It's a clean separation that scales well.
Tenant Resolution with proxy.ts (Next.js 16)
In Next.js 16, the traditional middleware.ts file has been replaced by proxy.ts, which runs at the edge and can intercept, rewrite, and redirect requests before they reach your application. This is the ideal place for tenant resolution — it runs before any Server Component code and adds minimal latency.
The following proxy.ts file handles subdomain extraction, custom domain lookup, and local development:
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
// Custom domain -> tenant mapping
// In production, this would be a database lookup or API call
const CUSTOM_DOMAIN_MAP: Record<string, string> = {
'app.acmecorp.com': 'acme',
'dashboard.widgets.io': 'widgets',
};
export function proxy(request: NextRequest) {
const url = request.nextUrl.clone();
const hostname = request.headers.get('host') || '';
// Define your root domain
const ROOT_DOMAIN = process.env.NEXT_PUBLIC_ROOT_DOMAIN || 'yourdomain.com';
// Skip static files and API routes
if (
url.pathname.startsWith('/_next') ||
url.pathname.startsWith('/api') ||
url.pathname.includes('.')
) {
return NextResponse.next();
}
let tenant: string | null = null;
// 1. Check for custom domain
if (!hostname.endsWith(ROOT_DOMAIN) && !hostname.includes('localhost')) {
tenant = CUSTOM_DOMAIN_MAP[hostname] || null;
if (!tenant) {
// Unknown custom domain — could redirect to marketing site
return NextResponse.redirect(new URL(`https://${ROOT_DOMAIN}`));
}
}
// 2. Check for subdomain
if (!tenant) {
const subdomain = extractSubdomain(hostname, ROOT_DOMAIN);
if (subdomain && subdomain !== 'www') {
tenant = subdomain;
}
}
// 3. No tenant detected — serve marketing site
if (!tenant) {
return NextResponse.next();
}
// 4. Set tenant in headers for Server Components to read
const headers = new Headers(request.headers);
headers.set('x-tenant', tenant);
headers.set('x-tenant-domain', hostname);
// 5. Rewrite to tenant route group
url.pathname = `/tenant${url.pathname}`;
return NextResponse.rewrite(url, {
request: { headers },
});
}
function extractSubdomain(hostname: string, rootDomain: string): string | null {
// Handle localhost development: acme.localhost:3000
if (hostname.includes('localhost')) {
const parts = hostname.split('.');
if (parts.length > 1) {
return parts[0];
}
return null;
}
// Handle Vercel preview deployments
if (hostname.includes('.vercel.app')) {
const parts = hostname.split('.');
if (parts.length > 2) {
return parts[0];
}
return null;
}
// Handle production: acme.yourdomain.com
if (hostname.endsWith(rootDomain)) {
const subdomain = hostname.replace(`.${rootDomain}`, '');
if (subdomain && subdomain !== hostname) {
return subdomain;
}
}
return null;
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};
So what does this proxy actually do? Four things: it checks if the incoming hostname is a registered custom domain, falls back to subdomain extraction, sets the resolved tenant slug in a request header (x-tenant), and rewrites the URL to a /tenant path prefix that maps to the (tenant) route group. If no tenant is detected, the request passes through to the marketing site unchanged.
For Next.js 15 and Earlier (middleware.ts)
If you're using Next.js 15 or an earlier version, the same logic goes into middleware.ts instead. The API is nearly identical:
import { NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
const url = request.nextUrl.clone();
const hostname = request.headers.get('host') || '';
const ROOT_DOMAIN = process.env.NEXT_PUBLIC_ROOT_DOMAIN || 'yourdomain.com';
let tenant: string | null = null;
// Same subdomain and custom domain resolution logic as proxy.ts
const subdomain = extractSubdomain(hostname, ROOT_DOMAIN);
if (subdomain && subdomain !== 'www') {
tenant = subdomain;
}
if (!tenant) {
return NextResponse.next();
}
const headers = new Headers(request.headers);
headers.set('x-tenant', tenant);
url.pathname = `/tenant${url.pathname}`;
return NextResponse.rewrite(url, {
request: { headers },
});
}
// extractSubdomain function remains the same
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};
When you upgrade to Next.js 16, there's a codemod available to automatically migrate your middleware.ts to proxy.ts. The core logic stays the same — only the file name and export signature change.
Tenant Context in Server Components
With the tenant identifier set in request headers by the proxy, Server Components can read it using the headers() function. Let's create a utility function to keep this consistent across your application:
// lib/tenant.ts
import { headers } from 'next/headers';
import { cache } from 'react';
import { db } from '@/lib/db';
import { tenants } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
export type Tenant = {
id: string;
slug: string;
name: string;
logoUrl: string | null;
primaryColor: string;
plan: 'free' | 'pro' | 'enterprise';
};
export const getTenant = cache(async (): Promise<Tenant> => {
const headersList = await headers();
const tenantSlug = headersList.get('x-tenant');
if (!tenantSlug) {
throw new Error('No tenant context found. Ensure proxy.ts is configured.');
}
const tenant = await db.query.tenants.findFirst({
where: eq(tenants.slug, tenantSlug),
});
if (!tenant) {
throw new Error(`Tenant not found: ${tenantSlug}`);
}
return tenant;
});
export async function getTenantSlug(): Promise<string> {
const headersList = await headers();
const slug = headersList.get('x-tenant');
if (!slug) throw new Error('No tenant context');
return slug;
}
The cache() wrapper from React is doing something important here — it ensures that multiple calls to getTenant() within the same request only hit the database once. This matters because you'll be calling this function in layouts, pages, and data access functions throughout the request lifecycle.
Here's how you'd use it in your tenant layout to apply branding:
// app/(tenant)/layout.tsx
import { getTenant } from '@/lib/tenant';
export default async function TenantLayout({
children,
}: {
children: React.ReactNode;
}) {
const tenant = await getTenant();
return (
<div
style={{
'--primary-color': tenant.primaryColor,
} as React.CSSProperties}
>
<header className="tenant-header">
{tenant.logoUrl && (
<img src={tenant.logoUrl} alt={tenant.name} />
)}
<h1>{tenant.name}</h1>
</header>
<main>{children}</main>
</div>
);
}
Tenant-Aware Data Access Layer
Honestly, this is the most critical part of any multi-tenant application. A single missing WHERE tenant_id = ? clause can leak data between tenants, and that's a severe security vulnerability you really don't want to deal with. The solution is a data access layer that enforces tenant scoping by default — no exceptions.
Database Schema with Drizzle ORM
Define your schema with a tenantId column on every tenant-scoped table:
// lib/db/schema.ts
import { pgTable, text, timestamp, uuid, varchar } from 'drizzle-orm/pg-core';
export const tenants = pgTable('tenants', {
id: uuid('id').primaryKey().defaultRandom(),
slug: varchar('slug', { length: 63 }).notNull().unique(),
name: varchar('name', { length: 255 }).notNull(),
logoUrl: text('logo_url'),
primaryColor: varchar('primary_color', { length: 7 }).default('#0066ff'),
plan: varchar('plan', { length: 20 }).default('free'),
customDomain: varchar('custom_domain', { length: 255 }),
createdAt: timestamp('created_at').defaultNow(),
});
export const projects = pgTable('projects', {
id: uuid('id').primaryKey().defaultRandom(),
tenantId: uuid('tenant_id').notNull().references(() => tenants.id),
name: varchar('name', { length: 255 }).notNull(),
description: text('description'),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow(),
});
export const members = pgTable('members', {
id: uuid('id').primaryKey().defaultRandom(),
tenantId: uuid('tenant_id').notNull().references(() => tenants.id),
userId: uuid('user_id').notNull(),
role: varchar('role', { length: 20 }).default('member'),
joinedAt: timestamp('joined_at').defaultNow(),
});
Scoped Data Access Functions
Now create data access functions that always require and enforce the tenant context:
// lib/db/queries.ts
import { db } from '@/lib/db';
import { projects, members, tenants } from '@/lib/db/schema';
import { eq, and } from 'drizzle-orm';
import { getTenant } from '@/lib/tenant';
export async function getTenantProjects() {
const tenant = await getTenant();
return db
.select()
.from(projects)
.where(eq(projects.tenantId, tenant.id))
.orderBy(projects.createdAt);
}
export async function getTenantProject(projectId: string) {
const tenant = await getTenant();
const project = await db
.select()
.from(projects)
.where(
and(
eq(projects.id, projectId),
eq(projects.tenantId, tenant.id)
)
)
.limit(1);
return project[0] || null;
}
export async function createProject(data: { name: string; description?: string }) {
const tenant = await getTenant();
return db.insert(projects).values({
tenantId: tenant.id,
name: data.name,
description: data.description,
}).returning();
}
export async function getTenantMembers() {
const tenant = await getTenant();
return db
.select()
.from(members)
.where(eq(members.tenantId, tenant.id));
}
Notice how every function calls getTenant() and uses the tenant ID to scope queries. There's simply no way to accidentally query data across tenants because the data access layer always enforces the boundary. This pattern saved me from a nasty data leak bug on a past project — well worth the minor boilerplate. For an in-depth look at Drizzle ORM setup and patterns, check out our guide on Next.js Database Integration with Drizzle ORM.
Tenant Configuration and Theming
A hallmark of polished SaaS products is per-tenant branding. Tenants expect to customize their workspace with their logo, brand colors, and company name. The approach here is straightforward: store this configuration in the tenants table and load it in the root tenant layout using CSS custom properties.
// app/(tenant)/layout.tsx
import { getTenant } from '@/lib/tenant';
import { TenantProvider } from '@/components/tenant-provider';
import './tenant.css';
export async function generateMetadata() {
const tenant = await getTenant();
return {
title: {
template: `%s | ${tenant.name}`,
default: tenant.name,
},
};
}
export default async function TenantLayout({
children,
}: {
children: React.ReactNode;
}) {
const tenant = await getTenant();
return (
<div
className="tenant-root"
style={{
'--tenant-primary': tenant.primaryColor,
'--tenant-primary-light': `${tenant.primaryColor}20`,
} as React.CSSProperties}
>
<TenantProvider tenant={tenant}>
<nav className="tenant-nav">
{tenant.logoUrl ? (
<img src={tenant.logoUrl} alt={tenant.name} className="tenant-logo" />
) : (
<span className="tenant-name">{tenant.name}</span>
)}
</nav>
<main className="tenant-content">{children}</main>
</TenantProvider>
</div>
);
}
The CSS file then uses these custom properties for consistent branding throughout the tenant experience:
/* app/(tenant)/tenant.css */
.tenant-nav {
background-color: var(--tenant-primary);
color: white;
padding: 1rem 2rem;
}
.tenant-root button.primary {
background-color: var(--tenant-primary);
color: white;
}
.tenant-root .highlight {
background-color: var(--tenant-primary-light);
}
Simple, but effective. Tenants get their own branded experience without any complicated theme engine.
Authentication in Multi-Tenant Apps
Authentication in a multi-tenant application introduces a critical constraint: a user authenticated on one tenant must not be able to access another tenant's data just by changing the URL. You need to scope sessions to tenants, full stop.
Tenant-Aware Auth.js v5 Configuration
Extend your Auth.js configuration to include the tenant context in the session and JWT tokens:
// lib/auth/config.ts
import NextAuth from 'next-auth';
import { DrizzleAdapter } from '@auth/drizzle-adapter';
import { db } from '@/lib/db';
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: DrizzleAdapter(db),
callbacks: {
async session({ session, token }) {
if (token.tenantId) {
session.tenantId = token.tenantId as string;
session.tenantRole = token.tenantRole as string;
}
return session;
},
async jwt({ token, account }) {
// On sign-in, look up the user's membership in the current tenant
if (account) {
const headersList = await import('next/headers').then(m => m.headers());
const tenantSlug = headersList.get('x-tenant');
if (tenantSlug) {
const tenant = await db.query.tenants.findFirst({
where: eq(tenants.slug, tenantSlug),
});
if (tenant) {
const membership = await db.query.members.findFirst({
where: and(
eq(members.tenantId, tenant.id),
eq(members.userId, token.sub!)
),
});
token.tenantId = tenant.id;
token.tenantRole = membership?.role || 'member';
}
}
}
return token;
},
},
cookies: {
sessionToken: {
name: '__session',
options: {
httpOnly: true,
sameSite: 'lax',
path: '/',
// Use the root domain so cookies work across subdomains
domain: `.${process.env.NEXT_PUBLIC_ROOT_DOMAIN}`,
secure: process.env.NODE_ENV === 'production',
},
},
},
});
The cookie domain is set to the root domain prefixed with a dot (e.g., .yourdomain.com), which allows the session cookie to be shared across all subdomains. The tenant context gets embedded in the JWT so that every authenticated request carries the tenant scope. For a deeper dive into Auth.js v5 patterns, check out our guides on Build a Complete Auth System with Auth.js v5 and Next.js Role-Based Access Control.
Database Strategies
The choice of database architecture has a big impact on your multi-tenant app's security, performance, and operational complexity. There are three main strategies, and picking the right one early matters more than you might think.
Shared Database with tenant_id Column
This is the most common approach and honestly the one I'd recommend for most SaaS applications. All tenants share the same database and tables. Every row includes a tenant_id column, and your data access layer filters by it. It's simple to manage, supports the most tenants per database instance, and makes cross-tenant analytics possible when you need them.
Schema-Per-Tenant (PostgreSQL)
PostgreSQL supports multiple schemas within a single database. Each tenant gets their own schema (e.g., tenant_acme.projects, tenant_widgets.projects). This provides stronger isolation than a shared table approach while keeping all tenants in one database for easier backups and migrations.
Database-Per-Tenant
Each tenant gets a completely separate database. Maximum isolation, and it makes data residency compliance straightforward. The downside? It's the hardest to manage at scale — migrations must be applied to every database, and connection pooling becomes a real headache.
Strategy Comparison
| Criteria | Shared DB + tenant_id | Schema-Per-Tenant | DB-Per-Tenant |
|---|---|---|---|
| Setup complexity | Low | Medium | High |
| Data isolation | Application-level | Schema-level | Full isolation |
| Migration complexity | Single migration | Per-schema migration | Per-database migration |
| Max tenants | Thousands+ | Hundreds | Tens to hundreds |
| Data residency | Difficult | Possible | Easy |
| Backup/restore per tenant | Complex | Moderate | Easy |
| Cross-tenant queries | Easy | Possible | Difficult |
| Cost efficiency | Best | Good | Highest cost |
For the vast majority of SaaS startups, start with the shared database approach. It's the simplest to build, deploy, and maintain. Move to schema-per-tenant or database-per-tenant only when you have specific compliance or isolation requirements that truly demand it.
Custom Domain Support
Custom domains are a premium feature that enterprise tenants value highly (and will pay extra for). Implementing them requires three pieces: a database mapping, DNS configuration, and SSL certificate handling.
Domain-to-Tenant Mapping
Store custom domains in your tenants table (or a separate domains table if tenants can have multiple custom domains):
// lib/db/domains.ts
import { db } from '@/lib/db';
import { tenants } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
import { cache } from 'react';
export const resolveTenantByDomain = cache(async (domain: string) => {
const tenant = await db.query.tenants.findFirst({
where: eq(tenants.customDomain, domain),
});
return tenant?.slug || null;
});
// For use in proxy.ts, use a lightweight fetch instead of Drizzle
// since proxy runs at the edge and may not have full Node.js APIs
export async function resolveDomainAtEdge(domain: string): Promise<string | null> {
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_APP_URL}/api/tenants/resolve?domain=${domain}`,
{ next: { revalidate: 300 } } // Cache for 5 minutes
);
if (!res.ok) return null;
const data = await res.json();
return data.tenantSlug;
} catch {
return null;
}
}
DNS and SSL Configuration
Tenants need to create a CNAME record pointing their custom domain to your application. On Vercel, SSL certificates are automatically provisioned for any domain added to your project — which is honestly one of the nicest parts of using Vercel for multi-tenant apps. You can programmatically add domains using the Vercel API:
// lib/vercel-domains.ts
export async function addDomainToVercel(domain: string) {
const response = await fetch(
`https://api.vercel.com/v10/projects/${process.env.VERCEL_PROJECT_ID}/domains`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.VERCEL_API_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: domain }),
}
);
const data = await response.json();
if (!response.ok) {
throw new Error(`Failed to add domain: ${data.error?.message}`);
}
return data;
}
export async function removeDomainFromVercel(domain: string) {
const response = await fetch(
`https://api.vercel.com/v9/projects/${process.env.VERCEL_PROJECT_ID}/domains/${domain}`,
{
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.VERCEL_API_TOKEN}`,
},
}
);
return response.ok;
}
export async function verifyDomainOnVercel(domain: string) {
const response = await fetch(
`https://api.vercel.com/v9/projects/${process.env.VERCEL_PROJECT_ID}/domains/${domain}/verify`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.VERCEL_API_TOKEN}`,
},
}
);
return response.json();
}
Integrating the custom domain flow into proxy.ts is straightforward. Replace the static CUSTOM_DOMAIN_MAP from the earlier example with a call to your domain resolution endpoint, and the rest of the tenant resolution logic works identically.
Deploying to Vercel
Vercel's infrastructure is particularly well-suited for multi-tenant Next.js apps thanks to its native support for wildcard domains, automatic SSL, and edge network.
Wildcard Domain Setup
- In your Vercel project settings, add your root domain (e.g.,
yourdomain.com). - Add a wildcard domain:
*.yourdomain.com. - Configure your DNS with a wildcard A or CNAME record pointing to Vercel.
- Vercel automatically provisions SSL certificates for the wildcard domain.
Environment Variables
Set the following environment variables in your Vercel project:
NEXT_PUBLIC_ROOT_DOMAIN=yourdomain.com
NEXT_PUBLIC_APP_URL=https://yourdomain.com
DATABASE_URL=postgresql://...
VERCEL_PROJECT_ID=prj_...
VERCEL_API_TOKEN=...
ISR for Tenant Pages
Use Incremental Static Regeneration to serve tenant-specific pages from the edge cache. This can dramatically reduce your database load and improve response times:
// app/(tenant)/page.tsx
import { getTenant } from '@/lib/tenant';
import { getTenantProjects } from '@/lib/db/queries';
export const revalidate = 60; // Revalidate every 60 seconds
export default async function TenantDashboard() {
const tenant = await getTenant();
const projects = await getTenantProjects();
return (
<div>
<h1>Welcome to {tenant.name}</h1>
<div className="project-grid">
{projects.map((project) => (
<div key={project.id} className="project-card">
<h3>{project.name}</h3>
<p>{project.description}</p>
</div>
))}
</div>
</div>
);
}
Testing Multi-Tenant Apps Locally
Testing subdomain routing locally requires a small amount of setup because browsers handle localhost subdomains differently than production domains. Don't worry though — it's easier than it sounds.
Using *.localhost
Modern browsers (Chrome, Edge, Firefox) resolve *.localhost to 127.0.0.1 automatically. This means you can just visit acme.localhost:3000 in your browser and it works — no configuration needed. Your proxy.ts already handles this with the hostname.includes('localhost') check.
Custom Domain Testing with /etc/hosts
To test custom domain resolution locally, add entries to your /etc/hosts file:
# /etc/hosts
127.0.0.1 app.acmecorp.local
127.0.0.1 dashboard.widgets.local
Then update your proxy.ts to recognize .local domains in development:
// Add to extractSubdomain in proxy.ts
if (hostname.endsWith('.local')) {
// Treat .local domains as custom domains in development
return null; // Let the custom domain lookup handle it
}
Environment-Aware Configuration
Use environment variables to switch between local and production domain configurations:
// lib/config.ts
export const config = {
rootDomain: process.env.NEXT_PUBLIC_ROOT_DOMAIN || 'localhost:3000',
protocol: process.env.NODE_ENV === 'production' ? 'https' : 'http',
isLocal: process.env.NODE_ENV === 'development',
};
export function getTenantUrl(tenantSlug: string): string {
if (config.isLocal) {
return `${config.protocol}://${tenantSlug}.localhost:3000`;
}
return `${config.protocol}://${tenantSlug}.${config.rootDomain}`;
}
Performance Optimization
Multi-tenant apps face a unique performance challenge: every single request needs tenant resolution before any application logic runs. Here are strategies to keep that overhead minimal.
Cache Tenant Resolution
Use the use cache directive (Next.js 16) or React's cache() to avoid repeated database lookups for the same tenant within a request:
// lib/tenant.ts (Next.js 16 with use cache)
import { cacheLife } from 'next/cache';
export async function getTenantConfig(slug: string) {
'use cache';
cacheLife('minutes');
const tenant = await db.query.tenants.findFirst({
where: eq(tenants.slug, slug),
});
return tenant;
}
Edge-Optimized Proxy
The proxy.ts file runs at the edge by default on Vercel, which means tenant resolution happens at the CDN node closest to the user. Keep the proxy lightweight — avoid heavy database queries in the proxy itself. Instead, set the tenant identifier in headers and let Server Components handle the full tenant config lookup. This separation keeps your edge response times fast.
ISR for Tenant-Specific Content
Static pages that vary by tenant can use ISR with the tenant slug as part of the cache key. The rewrite in proxy.ts ensures that each tenant gets its own cached version because the rewritten URL includes the tenant context. Combined with revalidate intervals, most tenant page loads end up being served from the edge cache with zero database queries. That's a huge win.
Connection Pooling
In a shared database setup, use connection pooling (e.g., PgBouncer, Neon's built-in pooler, or Supabase's Supavisor) to handle the connection volume from many tenants. Without pooling, a burst of requests from multiple tenants can exhaust your database connections surprisingly fast.
Frequently Asked Questions
Can I use both subdomain and custom domain routing in the same Next.js app?
Yes, absolutely. The proxy.ts example in this guide handles both. It first checks if the hostname matches a registered custom domain, then falls back to subdomain extraction. Both paths end up setting the same x-tenant header, so the rest of your application code works identically regardless of how the tenant was resolved. This is the recommended approach: start with subdomains and layer custom domain support on top.
How do I handle database migrations in a multi-tenant setup?
With the shared database approach (which I recommend), migrations work exactly like a single-tenant app — you run one migration that applies to all tenants. For schema-per-tenant, you'll need to iterate over all schemas and apply the migration to each one. Tools like Drizzle Kit and Prisma Migrate support this with custom scripts. For database-per-tenant, you need a migration runner that connects to each database sequentially or in parallel.
What is the difference between proxy.ts and middleware.ts for multi-tenant routing?
In Next.js 16, proxy.ts replaces middleware.ts as the request interception layer. The functionality is nearly identical for multi-tenant use cases — both intercept requests at the edge, can read headers, and can rewrite URLs. The main difference is the API surface and file naming convention. A codemod is available to migrate from middleware.ts to proxy.ts automatically. If you're on Next.js 15 or earlier, use middleware.ts with the same logic shown in the proxy.ts examples.
How do I test subdomain routing locally in Next.js?
Modern browsers resolve *.localhost to 127.0.0.1, so you can access acme.localhost:3000 directly. Your proxy.ts should detect the localhost hostname and extract the subdomain from the first segment. For custom domain testing, add entries to /etc/hosts mapping your test domains to 127.0.0.1. No additional tools or proxies needed.
Should I use a shared database or separate databases per tenant?
For most SaaS applications, a shared database with a tenant_id column is the right choice. It's simpler to build, deploy, migrate, and monitor. Separate databases per tenant make sense when you have strict data residency requirements (e.g., EU data must stay in EU), need to let tenants export or delete their database independently, or when tenants have vastly different data volumes that would benefit from independent scaling. Start shared and split later if needed — the data access layer pattern in this guide makes that migration straightforward because all queries already go through tenant-scoped functions.