Resolving Tenants at the Edge by Domain and Path
This guide, part of Multi-Tenant Architecture Patterns, implements the first and most important step of any multi-tenant headless frontend: deciding, for each incoming request, which tenant it belongs to. It covers resolution by custom domain, by subdomain and by path prefix, rewriting requests to tenant-scoped routes, rejecting unknown hosts and keeping preview requests correctly scoped.
Everything downstream depends on this decision. The data layer fetches the tenant’s content with the tenant’s token, the cache stores the response under the tenant’s key, and the page renders with the tenant’s design tokens. If resolution is ambiguous or falls back to a default, every later layer faithfully does the wrong thing. Doing it once, at the edge, from a single registry, makes it both fast and consistent.
The Problem
A SaaS platform offering hosted help centres let customers use either a subdomain of the platform or their own domain. Its middleware derived the tenant from the first label of the host name, which worked for subdomains but not for custom domains: help.acme.com resolved to a tenant called help, which happened to exist as a demo account. For several hours after a customer pointed their domain at the platform, their visitors saw the demo help centre. Separately, preview links used the platform’s main domain with a query parameter for the tenant, and one editor’s draft appeared in another tenant’s preview because a cached preview response ignored the parameter.
How Edge Resolution Works
A robust resolver follows a fixed order and never guesses:
- Exact host match. Look up the full, normalized host name in the registry’s host index. This covers custom domains and subdomains alike, because both are registered explicitly.
- Path prefix match. For platform hosts that serve several tenants under path prefixes, look up the first path segment in the prefix index for that host.
- Reject. If neither matches, return a 404 page that belongs to no tenant and log the host. Never fall back to a default tenant.
After resolution, the middleware rewrites the request to an internal route that contains the tenant id, such as /_tenants/acme/pricing. Pages under that route read the tenant id from the route parameters, which makes the tenant explicit in every data function and every cache key. The public URL stays unchanged.
Implementation
The example uses Next.js middleware; the same logic applies in Cloudflare Workers, Netlify Edge Functions or any reverse proxy. The registry is loaded from an edge key-value store or a JSON endpoint and cached in memory for a short time.
// middleware.ts
import { NextResponse, type NextRequest } from "next/server";
interface Registry {
hosts: Record<string, string>; // "help.acme.com" -> "acme"
prefixHosts: Record<string, Record<string, string>>; // "platform.io" -> { "acme": "acme" }
fetchedAt: number;
}
let cached: Registry | null = null;
async function registry(): Promise<Registry> {
if (cached && Date.now() - cached.fetchedAt < 60_000) return cached;
const res = await fetch(process.env.TENANT_REGISTRY_URL!, { cache: "no-store" });
const data = (await res.json()) as Omit<Registry, "fetchedAt">;
cached = { ...data, fetchedAt: Date.now() };
return cached;
}
export async function middleware(req: NextRequest) {
const host = (req.headers.get("host") ?? "").toLowerCase().replace(/:\d+$/, "");
const reg = await registry();
const url = req.nextUrl.clone();
// 1. Exact host match (custom domains and subdomains are both registered explicitly).
let tenant = reg.hosts[host];
let rest = url.pathname;
// 2. Path prefix match on shared platform hosts.
if (!tenant && reg.prefixHosts[host]) {
const [, first, ...others] = url.pathname.split("/");
tenant = reg.prefixHosts[host][first];
rest = `/${others.join("/")}`;
}
// 3. Unknown: reject, never fall back to a default tenant.
if (!tenant) {
console.warn(JSON.stringify({ kind: "unknown_tenant_host", host, path: url.pathname }));
url.pathname = "/_unknown-host";
return NextResponse.rewrite(url, { status: 404 });
}
url.pathname = `/_tenants/${tenant}${rest === "/" ? "" : rest}`;
const res = NextResponse.rewrite(url);
res.headers.set("x-tenant", tenant); // useful for logs; never trusted as input downstream
return res;
}
export const config = { matcher: ["/((?!_next/|_tenants/|favicon.ico).*)"] };
Pages live under app/_tenants/[tenant]/... and receive the tenant id as a route parameter. Block direct public access to /_tenants/ paths, which the matcher above does by excluding them from rewriting; add a check in the tenant layout that the incoming host actually maps to the tenant in the route, so a crafted URL on another tenant’s domain cannot render the wrong content.
Preview requests
Preview must resolve the tenant the same way as delivery, from the host, not from a query parameter. Generate preview URLs on the tenant’s own domain, or on a preview subdomain registered per tenant such as preview.acme.com, and let draft mode apply there. Mark preview responses no-store. This keeps cookies, draft mode and caches scoped to one tenant automatically, and removes the class of bugs where a parameter is ignored somewhere along the way.
Configuration Reference
| Item | Recommendation | Why |
|---|---|---|
| Registry source | edge KV or JSON endpoint, cached for about a minute | Fast lookups, quick propagation of new tenants. |
| Host normalization | lowercase, strip port, no trailing dot | One key per host. |
| Resolution order | exact host, then path prefix, then reject | Deterministic, no guessing. |
| Unknown hosts | 404 with no tenant content, logged | Misconfigurations become visible. |
| Internal route | /_tenants/[tenant]/… |
Tenant explicit in every page and cache key. |
| Preview | per-tenant domain or subdomain | Draft mode and cookies stay tenant-scoped. |
Gotchas & Edge Cases
wwwand apex domains. Register bothacme.comandwww.acme.com, or redirect one to the other before resolution. Missing one of them is the most common onboarding mistake.- Deriving tenants from host labels. Splitting the host and taking the first label breaks on custom domains, as in the problem above. Always look up the full host.
- Registry propagation. A new tenant may be unreachable until the registry cache refreshes. Keep the cache short, or push invalidations to the edge when the registry changes.
- Crafted internal paths. If
/_tenants/other/...can be requested directly on one tenant’s domain, it can render another tenant’s content under the wrong domain. Verify host and route tenant agree.
Worked Example
The help centre platform replaced its label-based resolution with the registry lookup, registered all existing custom domains and subdomains explicitly, and moved preview links to per-tenant preview subdomains. Unknown hosts now received a neutral 404, and a dashboard of unknown-host logs showed customers who had pointed domains at the platform before completing setup, which support could follow up proactively. The demo tenant was renamed to a reserved id that could never collide with a host label. No cross-tenant preview or delivery incident occurred in the following year.
Resolution and Caching Together
Resolution and caching must agree on what identifies a response. When tenants are resolved by host, the host is already part of every CDN cache key, so tenants cannot share cached pages. When tenants share a host and are resolved by path prefix, the path carries the tenant, which also keeps cache entries separate. The dangerous case is resolution from anything the CDN does not include in its key by default, such as a cookie or a header added by a proxy: responses can then be cached under a key that does not contain the tenant and served to another tenant’s visitors. Resolve only from host and path, and if another signal is ever needed, add it to the cache key explicitly before using it. Inside the app, include the resolved tenant id in every data cache key and tag, as covered in tenant-aware cache invalidation.
Rollout Checklist
- Build a registry with explicit host and path prefix indexes.
- Resolve exact host first, then path prefix, and reject everything else with a 404.
- Rewrite to an internal tenant route and read the tenant from route parameters.
- Verify in the tenant layout that host and route tenant agree.
- Move preview to per-tenant domains or subdomains, uncached.
- Log and review unknown hosts regularly.
Frequently Asked Questions
Should the registry live in the CMS?
It can, as a dedicated content type edited by the platform team, published to an edge store on change. Keeping it in code works for small, stable portfolios.
How fast must the lookup be?
It runs on every request, so it should be an in-memory map lookup after the first load. Never call a database or the CMS per request for resolution.
Can a tenant have several domains?
Yes. Register each host for the tenant and choose one canonical domain; redirect the others to it for SEO, or set canonical URLs accordingly.
Should resolution happen in the CDN or in the app?
In the first code that runs for a request and can read the registry, usually edge middleware. Resolving earlier, in CDN configuration, duplicates the registry; resolving later, in pages, leaves the tenant implicit for too long.
What about local development?
Register development hosts such as acme.localhost in a local registry file, so developers exercise the same resolution code as production.