Next routes a subdomain with a middleware.ts that rewrites
acme.example.com/settings to /acme/settings before matching, and the
route tree never learns a host was involved. Here the same rule is the
router’s own, so the build can see it: typed routes, one stored page per
tenant, and no middleware to write.
The rule
A request from a host that is not the site’s own is matched with the host in front of the path:
| request | matched as | file |
|---|---|---|
example.com/admin |
/admin |
app/admin/page.tsx |
admin.example.com/ |
/admin |
app/admin/page.tsx — the same file |
acme.example.com/settings |
/acme/settings |
app/[domain]/settings/page.tsx, domain: "acme" |
acme.com/settings |
/acme.com/settings |
the same file, domain: "acme.com" |
A subdomain of the site contributes its label; any other host contributes the
whole host. The site’s own hosts — the one in the root layout’s
metadataBase, www. of it, and any named in rscKit({ hosts }) — contribute
nothing, so the apex keeps path routing and an app adds tenants without moving
a file. localhost and an ip address are always the site’s own.
Nothing to configure for that: metadataBase names the apex, and a
directory does the rest. hosts is for a name that is neither the apex nor a
subdomain of it and is still the site rather than a tenant — a staging or
internal name, or a second brand domain:
rscKit({ hosts: ['app.internal', 'example.co.uk'] })Listing a subdomain there makes it the site by path instead of a tenant —
the “app on app.example.com, marketing on the apex” split — which is a
choice, not a requirement.
And only when a route could answer it: a [domain] (or [host]) directory at
the top of app/, or a directory named for the host. Any other parameter
name at the top of app/ — [collection], [slug] — is a path segment, as
it is in Next. An app with a metadataBase and no tenant tree routes every
host by path, and a proxy that forwards to the app by an internal name is not
read as a tenant called internal.
The visitor’s url is untouched: acme.example.com/settings stays in the
address bar, and a link to /billing on that page goes to
acme.example.com/billing. Only the match changed.
A tenant tree
Whether the tenant exists is a guard, so it goes in middleware.ts. It runs
before anything is sent, on every path, and its notFound() is a real 404.
Its argument is the route’s params:
import { notFound } from '@rsc-kit/core/not-found';
import { tenantByDomain } from '@/lib/tenants';
export default async function knownTenant({ domain }) {
if (!(await tenantByDomain(domain))) notFound(); // "acme" or "acme.com", as stored
}The layout is handed the same params, as a promise. A route that lists no
urls is stored as one shell for every tenant, so what reads the tenant goes
under <Suspense>; awaited above every boundary, there is no value to store
the shell with, and the build says so and names the layout:
import { Suspense } from 'react';
import { tenantByDomain } from '@/lib/tenants';
async function TenantName({ params }) {
const { domain } = await params;
return (await tenantByDomain(domain))!.name;
}
export default function TenantLayout({ params, children }) {
return (
<>
<header>
<Suspense fallback={null}>
<TenantName params={params} />
</Suspense>
</header>
{children}
</>
);
}A layout sees its own segments’ params and those above it, never a deeper
one’s, and it is rendered again when one of its own values changes:
acme.example.com/settings to globex.example.com/settings renders the
tenant layout for globex, while the root layout above it stays mounted.
[domain] is an ordinary dynamic segment: params.domain in every page and
layout below it, route('/[domain]/settings', { domain }) typed, loading.tsx
and error.tsx where you put them. A directory named for a host,
app/admin/, wins over [domain] the way a static segment wins over a
parameter anywhere else.
One difference from a parameter deeper in the tree: a [domain] at the top
of app/ binds only from a host, never from a path. example.com/nope
is a 404, not a tenant called nope, and acme.example.com/ cannot be
reached as example.com/acme. Next has no such guard — its [domain] folder
matches any path once the rewrite is in place — which is why Next apps tuck
the tenant tree under a route group.
Domains in a database
generateStaticParams on the tenant route is the hook. The listed hosts are
rendered at build and stored, one file per host; a host added afterwards
falls through to the plain tree, or — if the page reads the request — renders
on demand and resolves at request time:
export async function generateStaticParams() {
const tenants = await db.tenant.findMany({ select: { domain: true } });
return tenants.map((t) => ({ domain: t.domain }));
}A tenant’s page that reads cookies() or awaits connection() is dynamic
for that tenant and stored for none, exactly as any page is.
Locally
Keep metadataBase as the production host. localhost and an ip address
are always the site’s own, so the dev server routes by path as it always
did, and nothing changes until a [domain] directory exists. To try a tenant
without DNS, send the host the router will see in production:
curl -H 'X-Forwarded-Host: acme.example.com' http://localhost:3000/Or point acme.example.com at 127.0.0.1 in /etc/hosts and open it on
the dev server’s port in a browser.
Behind a proxy
The host is read from X-Forwarded-Host first, then Host. A load balancer
that terminates TLS and forwards to the app by an internal name still routes
by the name the visitor typed.
Not for a static export
An export is served by a file server, which sees no host. Host routing is a server feature; an exported site is the site’s own on every host it is served from.
Coming from Next
Delete the rewrite in middleware.ts and the [domain] directory works as
it did; the segment binds the same value the rewrite put there. Next’s
rewrite() for anything else is not here — a host maps to a tree by file,
not by code.