---
title: "Domains and subdomains"
description: "A host as a route segment — admin.example.com reaches app/admin, a tenant's host binds [domain] — with nothing to rewrite."
---

> Documentation Index
> Fetch the complete documentation index at: https://docs.rsc-kit.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Domains and subdomains

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.

Source: https://docs.rsc-kit.dev/guides/domains/index.mdx
