All insights
Engineering12 min read

Multi-Tenant Architecture in Next.js: A Production Guide

Three patterns for building multi-tenant SaaS in Next.js — subdomain routing, path-based tenancy, and database-level isolation — with their trade-offs.

What Multi-Tenancy Actually Means

A multi-tenant application serves multiple customers (tenants) from a single deployment. Each tenant's data is isolated but the codebase is shared. The three main approaches differ in how you identify which tenant is making a request.

Pattern 1: Subdomain-Based Routing

Each tenant gets their own subdomain: acme.yourapp.com, globex.yourapp.com.

This is the premium option — it looks professional and allows per-tenant custom domains. It requires wildcard DNS and SSL configuration.

Middleware approach:

code
// middleware.ts
import { NextResponse, type NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  const hostname = request.headers.get('host') ?? ''
  const subdomain = hostname.split('.')[0]

  // Pass tenant slug as a header to all route handlers
  const response = NextResponse.next()
  response.headers.set('x-tenant', subdomain)
  return response
}
code
// app/page.tsx — read tenant from headers
import { headers } from 'next/headers'

export default async function Page() {
  const headersList = await headers()
  const tenant = headersList.get('x-tenant')
  const data = await db.query.tenants.findFirst({
    where: eq(tenants.slug, tenant),
  })
  // ...
}

Wildcard DNS in next.config.ts:

code
const config: NextConfig = {
  async headers() {
    return [{ source: '/(.*)', headers: [{ key: 'x-tenant', value: '' }] }]
  },
}

Deploy on Vercel and add a wildcard domain *.yourapp.com. Vercel handles the SSL automatically.

Pattern 2: Path-Based Tenancy

Tenants are identified by a path prefix: yourapp.com/acme/..., yourapp.com/globex/....

Simpler to set up — no DNS configuration required. Works well for internal tools and B2B apps where vanity subdomains aren't important.

code
app/
  [tenant]/
    page.tsx        ← /acme, /globex
    dashboard/
      page.tsx      ← /acme/dashboard
code
// app/[tenant]/layout.tsx
export default async function TenantLayout({
  children,
  params,
}: {
  children: React.ReactNode
  params: Promise<{ tenant: string }>
}) {
  const { tenant } = await params
  const tenantData = await db.query.tenants.findFirst({
    where: eq(tenants.slug, tenant),
  })

  if (!tenantData) notFound()

  return (
    <TenantProvider tenant={tenantData}>
      {children}
    </TenantProvider>
  )
}

Pattern 3: Database-Level Isolation

How you isolate tenant data at the database level matters as much as routing. Three common approaches:

Shared tables with tenant_id column (most common):

code
CREATE TABLE projects (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id uuid NOT NULL REFERENCES tenants(id),
  name text NOT NULL,
  created_at timestamptz DEFAULT now()
);

CREATE INDEX ON projects(tenant_id);

Every query must include a tenant_id filter. Use Postgres Row Level Security to enforce this at the database level — it's the safest approach because it's enforced even if application code forgets the filter.

code
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

CREATE POLICY "tenant isolation"
  ON projects
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

Set the tenant context before queries:

code
async function withTenantContext<T>(tenantId: string, fn: () => Promise<T>) {
  return db.transaction(async (tx) => {
    await tx.execute(sql`SELECT set_config('app.tenant_id', ${tenantId}, true)`)
    return fn()
  })
}

Separate schemas per tenant (better isolation, more complexity):

code
CREATE SCHEMA tenant_acme;
CREATE TABLE tenant_acme.projects (...);

Dynamically set search_path to the tenant schema before queries. More isolation, but harder to run cross-tenant analytics and schema migrations become O(n tenants).

Separate databases (maximum isolation, highest cost):

Each tenant gets their own database instance. Used in enterprise contracts with strict data residency requirements. The operational overhead is significant — only consider this if it's a hard requirement.

Handling Tenant Resolution in Auth

Always resolve the tenant as part of your auth flow — don't trust the URL alone.

code
// lib/auth.ts
export async function getCurrentTenantUser(request: NextRequest) {
  const session = await getSession(request)
  if (!session) return null

  const tenantSlug = resolveTenantFromRequest(request)
  const membership = await db.query.memberships.findFirst({
    where: and(
      eq(memberships.userId, session.userId),
      eq(memberships.tenantSlug, tenantSlug)
    ),
  })

  if (!membership) return null
  return { user: session.user, tenant: membership.tenant, role: membership.role }
}

A user authenticated to acme.yourapp.com should never be able to read data from globex.yourapp.com — even if they guess the path. Verify membership on every request.

Caching Considerations

Per-tenant data cannot be cached globally. Use unstable_cache with the tenant ID as part of the cache key:

code
import { unstable_cache } from 'next/cache'

export const getTenantProjects = (tenantId: string) =>
  unstable_cache(
    async () => db.query.projects.findMany({ where: eq(projects.tenantId, tenantId) }),
    [`projects-${tenantId}`],
    { revalidate: 60, tags: [`tenant-${tenantId}`] }
  )()

Invalidate the tag on mutations:

code
import { revalidateTag } from 'next/cache'
revalidateTag(`tenant-${tenantId}`)

Choosing a Pattern

RequirementPattern
Custom domains per tenantSubdomain routing
Simple B2B internal toolPath-based
Startup, moving fastShared table + tenant_id
Enterprise data residencySeparate schema/database
Regulatory compliance (HIPAA/SOC2)Separate schema minimum

Multi-tenancy is one of the architectural decisions that's expensive to change later. Pick the isolation level your target customers actually require — not the one that sounds most impressive on a product page.

Stay informed

Get our monthly deep dives.

Engineering, design, and growth insights — once a month. No spam.

Browse all resources