skip to content

A multi-tenant Next.js app serves acme.example.com and globex.example.com from one deployment, using middleware to rewrite each request to /tenants/<tenant>/... . What does the rewrite buy you, and what must the app account for because the browser never sees the internal path?

level: seniorimportance: should knowfreq 35%

answer

  1. one route tree, many hostnames
  2. the prefix is server-side only
  3. params come from the internal path
  4. links stay in the public space
  5. routing is not authorisation

basics

~20 s

The rewrite lets one route tree serve every tenant while each keeps its own clean URLs, since the internal /tenants/<tenant> prefix stays server-side. The catch is that only middleware knows the tenant, so links, redirects and any generated URLs must be written in the public space, not the internal one.

solid answer

~50 s

Middleware reads the host — `request.headers.get('host')` or `request.nextUrl.hostname` — maps it to a tenant, and rewrites to `/tenants/<tenant><path>`. You get one deployment and one route tree, and the destination segment supplies `params.tenant` to the page, so tenant identity arrives through normal routing rather than a global. What the browser never learns is that prefix, and that has consequences. Every `<Link href>`, every `redirect()` and every absolute URL you generate — canonical tags, emails, OAuth callbacks — must use the public path, because a link to `/tenants/acme/settings` would put the internal path in the address bar. Middleware becomes a hard dependency of every request, so an unknown host has to have a defined answer. And the rewrite is routing, not authorisation: the page still has to scope its data by the tenant it received, or one tenant's URL will happily render another tenant's data.

code

typescript · 17 lines
typescript
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

const TENANTS = new Set(['acme', 'globex'])

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

  if (!TENANTS.has(tenant)) {
    return NextResponse.rewrite(new URL('/marketing', request.url))
  }

  const url = request.nextUrl.clone()
  url.pathname = `/tenants/${tenant}${url.pathname}`
  return NextResponse.rewrite(url)
}

go deeper

for a junior

Know the shape: middleware reads the host, rewrites to an internal /tenants/<tenant> path, and the browser keeps showing the tenant's own URL.

for a middle

Explain that the destination segment is what supplies params.tenant to the page, and that only public-space paths may appear in links because the internal prefix exists solely inside middleware.

for a senior

Cover the production edges — absolute URLs in emails and canonical tags built per host, an explicit branch for unknown hosts, logging the resolved tenant, and data queries scoped by tenant because the internal route is directly requestable.

for a principal

Own the isolation model: what a shared deployment means for blast radius and noisy neighbours, whether tenant separation lives in routing or in the data layer, and at what customer size a dedicated deployment beats a rewrite.

## The shape of the pattern One deployment, many hostnames. The route tree is written once under a dynamic tenant segment — `app/tenants/[tenant]/dashboard/page.tsx` — and middleware maps host to tenant on the way in: ```ts export function middleware(request: NextRequest) { const host = request.headers.get('host') ?? '' const tenant = host.split('.')[0] const url = request.nextUrl.clone() url.pathname = `/tenants/${tenant}${url.pathname}` return NextResponse.rewrite(url) } ``` A request for `https://acme.example.com/dashboard` is resolved as `/tenants/acme/dashboard`, and the response comes back under the URL the user asked for. The tenant never sees `/tenants/` anywhere. ## What you gain **One route tree, N tenants.** No duplicated pages, no per-tenant build, no deploy per customer. **Clean public URLs.** Each tenant's URLs look like the product is theirs. That matters for branding, for links people paste to each other, and for anything printed in a UI. **Tenant identity arrives through routing.** Because the destination has a `[tenant]` segment, the page receives it in `params` — the ordinary mechanism, typed, available in layouts and pages without a global or a context. This is the quiet win over stuffing the tenant into a header: routing already knows how to pass segments down. **A single place to resolve identity.** Host-to-tenant mapping lives in exactly one function. ## What you must account for ### Links must live in the public URL space The internal prefix is a server-side detail, and the moment you write `<Link href="/tenants/acme/settings">` you have leaked it into the address bar. Every link, every `redirect()` call from a Server Component or Action, every `router.push` must use the public path — `/settings`. Middleware re-applies the prefix. Teams usually enforce this by never constructing the internal path outside middleware, and by keeping a helper that builds public URLs. ### Absolute URLs are a separate problem Anything that leaves the app carries a host: canonical tags, sitemaps, OG images, password-reset emails, OAuth redirect URIs, webhook callbacks. These must be built from the *tenant's* host, not from a single configured base URL, or acme's users receive links pointing at globex or at a shared marketing domain. This is where the pattern most often breaks in production, because it fails silently in code paths nobody clicks during development. ### Unknown hosts need a defined answer A request arrives with a host that maps to no tenant — a stale DNS record, a scanner, a preview URL, the platform's own default domain. Decide explicitly: render a marketing page, return a 404, redirect to the apex. Deriving `tenant` from a substring and rewriting to a route that does not exist gives you an unhelpful failure, and a tenant slug taken from the host is untrusted input that should be validated against a known set rather than interpolated blindly into a path. ### Middleware is now on the critical path Every request depends on it. That raises the stakes on what it does: a slow lookup here is added to every page load, and an exception is a site-wide outage rather than one broken route. Keep the mapping cheap — a static map, or a cached lookup — and make the failure mode explicit. ### The rewrite is not authorisation This is the one that ends up in a security review. The rewrite decides *which route* runs; it does not decide what data that route may read. The page must scope every query by the `tenant` it received. And because `/tenants/acme/dashboard` is a real route, treat it as directly requestable — do not assume that a request can only reach it with the correct host attached. Authorisation belongs in the data access layer, checked against the session, not inferred from the URL that middleware constructed. ### Operational visibility When a ticket says "the dashboard is wrong on acme", nothing in the URL says a rewrite chose the route. Log the resolved tenant, or attach it as a response header in non-production, so you can tell at a glance which mapping ran. ## Why a rewrite rather than a redirect A redirect would work mechanically and is entirely wrong here: it would put `/tenants/acme/dashboard` in the address bar, expose the internal structure, hand every tenant the same-looking URL space, and add a round trip to every navigation. The whole point of the rewrite is that the mapping is invisible.

  • How does middleware determine which tenant a request belongs to?
    From the host — `request.headers.get('host')` or `request.nextUrl.hostname` — mapped against a known set of tenants. Validate rather than interpolate: a host is client-supplied, so an unrecognised value should hit an explicit branch (marketing page, 404, apex redirect) instead of producing a path to a route that does not exist.
  • What breaks if a developer writes <Link href="/tenants/acme/settings"> inside the app?
    Navigating that link makes the internal path the visible URL, so the user sees and can share `/tenants/acme/settings`. The prefix only stays hidden while middleware is the one applying it. Keep every link, redirect() call and router.push in the public space, and build them through a helper so the internal shape has one author.
  • Does the rewrite mean the page can trust the tenant in params?
    It can trust that routing put it there, not that the caller is entitled to it. `/tenants/acme/dashboard` is a real route and may be requested directly, so the page must check the session against that tenant and scope every query by it. Treat params.tenant as an identifier to authorise, never as proof of access.

saying these in an interview costs you the question

  • Treats the hidden prefix as an access control
  • Builds emails and canonical URLs from one fixed base URL
  • Interpolates an unvalidated host substring into the rewrite path
  • Links directly to the internal /tenants/... path
  • Has no defined behaviour for an unrecognised host

context