@rsc-kit/mcp
Version:
An MCP server over what an rsc-kit build decided: the routes, why each one is static or not, and what it costs the browser.
165 lines (126 loc) • 6.59 kB
Markdown
# 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.
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:
```ts title="vite.config.ts"
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:
```ts title="src/app/[domain]/middleware.ts"
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:
```tsx title="src/app/[domain]/layout.tsx"
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:
```ts title="src/app/[domain]/page.tsx"
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:
```bash
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.