Multi-Zones, Multi-Tenant, and Distributed Application Architecture15 min read
Module A-10·15 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.
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.
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.
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:
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.
Here's the question every team asks the first week they run multi-zone in production: a user logs in on the marketing zone (or the app zone, wherever the login form lives), then clicks a link that rewrites() sends to the docs zone. Are they still logged in?
By default, no. Each zone is a completely separate Next.js application with its own deployment, its own server, and — this is the part that catches people — its own idea of what a "session" is. If your auth library is configured per-zone with defaults, it'll set a cookie scoped however that zone's config says, and the other zone has no way to know the user is authenticated. The URL bar shows one unified domain, so users (reasonably) expect one unified session. What they get instead, if you haven't set this up deliberately, is being logged out every time they cross a zone boundary.
The fix is not "one zone owns the session and tells the others." There's no shared server-side session store that Next.js wires up for you across zones, and building a token-relay system between zones is more infrastructure than the problem needs. The actual pattern is simpler: every zone independently validates the same cookie.
That requires two things to line up across all zones:
The cookie must be scoped to the parent domain, not a subdomain or a zone-specific path. A cookie set with Domain=app.example.com is invisible to a request served from docs.example.com or even example.com/docs behind a rewrite, depending on how the rewrite is set up. Set it at the registrable parent domain — Domain=.example.com — so every zone sitting under example.com can read it.
Every zone must run the identical verification logic against the identical secret. If zone A signs session tokens with AUTH_SECRET=foo and zone B is configured with AUTH_SECRET=bar (or worse, a totally different auth library), zone B can't verify a cookie zone A issued. Zone B isn't "asking zone A if this user is logged in" — it's independently checking the token's signature, and it can only do that if it has the same key.
A concrete example with Auth.js (NextAuth), which is the common case since most teams already run it somewhere in the zone stack:
// auth.ts — identical in EVERY zone's deployment import NextAuth from 'next-auth';
export const { handlers, auth, signIn, signOut } = NextAuth({ providers: [/* ... */], secret: process.env.AUTH_SECRET, // same value in every zone's env cookies: { sessionToken: { name: 'next-auth.session-token', options: { domain: '.example.com', // parent domain — readable by every zone path: '/', httpOnly: true, sameSite: 'lax', secure: true, }, }, }, });
Deploy this same auth.ts (same AUTH_SECRET env var, same cookie config) to both the marketing zone and the docs zone. Neither zone is "the" auth server — a request to either one runs Auth.js's own JWT verification against the cookie it finds. If the signature checks out, that zone renders the authenticated view. If a user logs in on zone A, the cookie Auth.js sets is visible to zone B on the very next request, and zone B's own Auth.js instance verifies it independently.
The two failure modes worth knowing before you hit them in production:
Cookie scoped to a subdomain by accident. If any zone is deployed on its own subdomain (docs.example.com behind a rewrite from example.com/docs, rather than served path-based), double-check the Domain attribute actually resolves to a value covering all zones. This is the single most common reason "shared auth" silently doesn't share.
Secret drift between zones. Different teams, different deploy pipelines, different secrets managers — it's easy for zone B's AUTH_SECRET to fall out of sync with zone A's after a rotation. Rotate this secret through a single source of truth (a shared secrets manager entry all zone CI pipelines pull from), not copy-pasted into each zone's environment variables independently.
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.
// 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 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:
// 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)); }
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.
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:
// 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();
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.
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.
Every one of the tenant-routing patterns above deals with pages — which HTML gets rendered for which hostname. There's a separate, often-missed problem for white-labeled multi-tenant setups: where do a zone or deployment's static assets (_next/static/* — your JS chunks, CSS, fonts) get served from?
By default, they're served from the same origin as the page. That's fine until a tenant wants their deployment to look and feel like it's fully theirs — served entirely from assets.tenant-company.com, fronted by a CDN they control or that you've provisioned per-tenant, rather than everything hanging off your shared example.com origin. assetPrefix in next.config.ts is the knob for this:
With this set, every reference Next.js emits to _next/static/* — in the HTML <script>/<link> tags, in the client runtime's chunk-loading logic — points at https://cdn.tenant-a-company.com/_next/static/* instead of a relative path on the serving origin. That's the mechanism white-labeled deployments use: tenant A's custom domain fronts a CDN pointed at tenant A's asset origin, tenant B's fronts a different one, and the two never share a static asset path even though they might be running the same underlying build.
The constraint this implies — and it trips people up:assetPrefix gets baked into the HTML and the client bundle at build time. It is not something you can read from a request and swap in dynamically per-tenant inside Middleware or a Server Component — by the time a request arrives, the asset URLs are already fixed strings inside the already-built HTML and JS. This means:
If every tenant needs a genuinely different assetPrefix, you need a separate build per tenant (or per asset-origin), each with its own next.config.ts value baked in, not one shared build serving all tenants.
If tenants can share the same static assets — which is the common case, since white-labeling is usually about the domain and branding, not different compiled JavaScript — a single build with one shared CDN origin (or none at all) is simpler and is what most teams actually want. Reach for per-tenant assetPrefix only when tenants genuinely need assets served from their own domain or CDN account, not by default.
Tenant detection for the purpose of choosing which assetPrefix to bake in has to happen at build time (an env var set per CI job, a build matrix keyed by tenant), not at request time. If you find yourself wanting "read the hostname, then pick the asset origin," that's a sign you actually want per-tenant builds, not a single dynamic build.
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.
Knowledge Check
When using a Multi-Zone architecture to host multiple independent Next.js applications on the same domain, what is a key limitation regarding navigation between the zones?
In a custom domain multi-tenancy architecture (e.g., app.customer.com), why is it recommended to use an edge-compatible store like Vercel Edge Config or Upstash Redis in Middleware, rather than querying a primary database like PostgreSQL?
What is the most common and generally recommended approach for enforcing tenant data isolation in a standard SaaS application utilizing a single database?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.