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.

Three ways a request identifies its tenantA custom domain such as acme.com, a subdomain such as acme.platform.io, or a path prefix such as platform.io/acme each map through the registry to the tenant id acme, after which the request is rewritten to the internal route /_tenants/acme/.acme.comacme.platform.ioplatform.io/acme/…Registry lookuphost, then prefixRewrite to/_tenants/acme/…
Whatever the public URL shape, the app sees one internal route pattern with the tenant id in it.

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:

  1. 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.
  2. 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.
  3. 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.

One request through the resolverA request for help.acme.com/pricing reaches the edge middleware, which normalizes the host, finds acme in the registry's host index, rewrites the request to /_tenants/acme/pricing and passes it to the app, which fetches acme's content with acme's token.VisitorEdge middlewareRegistry cacheApp routeGET help.acme.com/pricinghost help.acme.comtenant acmerewrite /_tenants/acme/pricingfetch with acme token,cache key acme:…acme pricing page
The registry lookup is a cached, in-memory map, so resolution adds well under a millisecond.

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.

TypeScript
// 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

  • www and apex domains. Register both acme.com and www.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.

Requests resolved per path, one weekWeekly requests by resolution outcome after the change: exact host matches, path prefix matches, and rejected unknown hosts.Exact host match41.2 million requestsPath prefix match3.8 million requestsRejected, unknown host0.21 million requests
Rejected requests were almost all bots and domains pointed at the platform before onboarding finished.

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.