Skip to content

Domains and subdomains

A host as a route segment — admin.example.com reaches app/admin, a tenant's host binds [domain] — with nothing to rewrite.

Updated View as Markdown

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:

vite.config.tsts
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:

src/app/[domain]/middleware.tsts
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:

src/app/[domain]/layout.tsxtsx
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:

src/app/[domain]/page.tsxts
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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close