Back/Module A-10 Multi-Zones, Multi-Tenant, and Distributed Application Architecture
Module A-10·25 min read

Next.js multi-zones for independent team deploys, basePath and reverse proxy wiring, shared auth across zones, multi-tenant subdomain routing with per-tenant database isolation, and assetPrefix for tenant CDN origins.

A-10 — Multi-Zones, Multi-Tenant, and Distributed Architecture

Who this is for: Architects building applications that have outgrown a single Next.js codebase — either because different teams own different parts of the product (multi-zone), or because the same codebase needs to serve multiple customers with isolated data and custom domains (multi-tenant). This module covers the architectural patterns for both.


Multi-Zone Architecture — One Domain, Multiple Apps

A Next.js multi-zone deployment lets you host multiple independent Next.js applications on the same domain. Each application owns a path prefix, and a reverse proxy routes requests to the right app.

The canonical use case: a large organisation where the marketing site (/), the documentation (/docs), and the main application (/app) are maintained by different teams with different release cycles. Rather than forcing them all into one repository and one deployment, each is an independent Next.js app.

example.com/          → Marketing Next.js app (marketing team, deploys 3x/week)
example.com/docs/*    → Docs Next.js app (developer experience team, deploys on content changes)
example.com/app/*     → Application Next.js app (product team, deploys 5x/day)

On Vercel, this is configured through the project settings as a "multi-zone" setup — each project gets a Vercel domain, and a rewrites config in the primary project routes traffic:

ts
// next.config.ts (in the primary / zone) const config: NextConfig = { async rewrites() { return [ { source: '/docs/:path*', destination: 'https://docs-zone.vercel.app/docs/:path*', }, { source: '/app/:path*', destination: 'https://app-zone.vercel.app/app/:path*', }, ]; }, };

Each zone's next.config.ts sets a basePath so internal links resolve correctly:

ts
// next.config.ts (in the /docs zone) const config: NextConfig = { basePath: '/docs', };

Multi-Zone Constraints

Multi-zone has real constraints that trip up teams the first time they try it.

Client-side navigation between zones is not possible. The App Router's client-side navigation works within a single Next.js app. A link from the marketing zone to /docs/getting-started causes a full page navigation — the docs zone app boots fresh. There's no shared React tree between zones.

This is actually fine for the canonical use case (marketing → docs → app are all different products) but becomes a problem if you're trying to use multi-zone for micro-frontend architecture where seamless navigation is expected.

Shared layouts require duplication. If you want a consistent header across all zones, each zone must implement it separately. There's no "shared layout" that spans zones. The coordination problem is usually solved with a shared npm package for the header component.

Router Cache doesn't cross zones. Each zone has its own Router Cache. Navigating from the marketing zone to the docs zone clears the Router Cache.


When Multi-Zone Is the Right Choice

Use multi-zone when:

  • Different parts of the site have legitimately different deployment cadences
  • Different teams own different parts and shouldn't share CI/CD pipelines
  • You're migrating from one architecture to another incrementally (old Pages Router app on /legacy, new App Router app on everything else)
  • Bundle size and cold start time are affected by mixing concerns (a marketing site and a data-heavy application have very different dependencies)

Don't use multi-zone for:

  • Micro-frontend architecture where seamless navigation is required
  • Sharing significant state between zones
  • Performance-critical flows that span zone boundaries

Multi-Tenant Architecture — One App, Many Customers

Multi-tenancy is a different problem entirely. One Next.js application serves multiple customers (tenants), each with their own data, potentially their own domain, and sometimes their own branding.

The three flavours of multi-tenancy in Next.js:

Path-based: example.com/team-a/dashboard, example.com/team-b/dashboard Subdomain-based: team-a.example.com, team-b.example.com Custom domain: dashboard.team-a-company.com, portal.team-b.com


Path-Based Multi-Tenancy

The simplest. The tenant identifier is in the URL path, available to Server Components as a route param:

app/
  [tenant]/
    layout.tsx    ← load tenant config, validate slug
    dashboard/
      page.tsx
    settings/
      page.tsx
tsx
// app/[tenant]/layout.tsx export default async function TenantLayout({ children, params, }: { children: React.ReactNode; params: Promise<{ tenant: string }>; }) { const { tenant } = await params; const tenantConfig = await db.tenants.findUnique({ where: { slug: tenant } }); if (!tenantConfig) notFound(); return ( <TenantProvider config={tenantConfig}> {children} </TenantProvider> ); }

Subdomain-Based Multi-Tenancy with Middleware

Subdomain tenancy requires Middleware to extract the tenant from the hostname and inject it into the request:

ts
// middleware.ts export async function middleware(request: NextRequest) { const hostname = request.headers.get('host') ?? ''; const subdomain = hostname.split('.')[0]; // Skip for www, app (non-tenant subdomains) const nonTenantSubdomains = new Set(['www', 'app', 'api']); if (nonTenantSubdomains.has(subdomain)) { return NextResponse.next(); } // Rewrite the request to include the tenant in the URL const url = request.nextUrl.clone(); url.pathname = `/${subdomain}${url.pathname}`; return NextResponse.rewrite(url); }

This rewrite is invisible — team-a.example.com/dashboard is rewritten to serve content from /team-a/dashboard internally, but the URL in the browser stays as team-a.example.com/dashboard.


Custom Domain Multi-Tenancy

Custom domains — where tenants use their own domain (app.customer.com) pointing to your application — are the most complex variant. The tenant-to-domain mapping must be stored in a database and the Middleware must look it up.

The challenge: Middleware can't use Prisma. The solution is a lightweight, edge-compatible lookup:

ts
// middleware.ts export async function middleware(request: NextRequest) { const hostname = request.headers.get('host') ?? ''; // Skip your own domains if (hostname.endsWith('.example.com') || hostname === 'example.com') { return NextResponse.next(); } // Look up the tenant for this custom domain // Must be an edge-compatible lookup (HTTP API, not database) const tenantResponse = await fetch( `https://api.example.com/tenant-by-domain?domain=${hostname}`, { next: { revalidate: 300 } } // cache for 5 minutes ); if (!tenantResponse.ok) { return NextResponse.redirect(new URL('/404', request.url)); } const { tenantId } = await tenantResponse.json(); const url = request.nextUrl.clone(); url.pathname = `/${tenantId}${url.pathname}`; return NextResponse.rewrite(url); }

The next: { revalidate: 300 } on the lookup fetch is critical — without caching, every request to a custom domain triggers a lookup API call, adding latency and load to your lookup service.

For scale, replace the HTTP lookup with Vercel's Edge Config or Upstash Redis — both are edge-compatible and have sub-millisecond read latency for cached values.


Tenant Data Isolation

Multi-tenancy requires rigorous data isolation. The most common architecture:

Row-level isolation (single database): All tenants share a database. Every table has a tenantId column. All queries are scoped with WHERE tenantId = ?. Prisma makes this manageable with middleware:

ts
// lib/db.ts — Prisma client with tenant scoping import { PrismaClient } from '@prisma/client'; import { AsyncLocalStorage } from 'async_hooks'; const tenantStorage = new AsyncLocalStorage<string>(); export const db = new PrismaClient().$extends({ query: { $allModels: { async $allOperations({ args, query, model }) { const tenantId = tenantStorage.getStore(); if (tenantId && args.where !== undefined) { args.where = { ...args.where, tenantId }; } return query(args); }, }, }, }); export function withTenant<T>(tenantId: string, fn: () => T): T { return tenantStorage.run(tenantId, fn); }

Schema isolation (single database, multiple schemas): Each tenant gets a PostgreSQL schema. More isolation, more complexity, harder to query across tenants.

Database isolation (separate databases): Maximum isolation. Most expensive. Used when regulatory requirements mandate tenant data separation (healthcare, finance).

For most SaaS applications, row-level isolation with a Prisma extension is the correct default. It handles 99% of use cases and doesn't require complex database management.


Vercel Edge Config for Tenant Routing

Vercel Edge Config is a key-value store readable at the edge with sub-millisecond latency — purpose-built for the tenant routing problem:

ts
// middleware.ts import { get } from '@vercel/edge-config'; export async function middleware(request: NextRequest) { const hostname = request.headers.get('host') ?? ''; // Sub-millisecond lookup — Edge Config is always in memory at the edge const tenantId = await get<string>(`domain:${hostname}`); if (!tenantId) { return NextResponse.redirect(new URL('/not-found', request.url)); } const url = request.nextUrl.clone(); url.pathname = `/${tenantId}${url.pathname}`; return NextResponse.rewrite(url); }

Edge Config is populated by your backend when a new custom domain is added — you write to it via the Edge Config API after verifying the domain. The edge reads it in Middleware with no HTTP round trip.


Where We Go From Here

A-11 goes under the hood of the build system — Turbopack replacing Webpack, SWC replacing Babel, and what these changes mean for build times, bundle analysis, and custom configuration. After the architecture patterns of A-10, A-11 explains the engine that produces the deployable output.

Discussion