Every build writes the urls it found to .rsc-kit/rsc-routes.d.ts.
Link, visit, prefetch and Form accept only those.
import Link from '@rsc-kit/core/Link'
<Link href="/about">About</Link> // ✅
<Link href="/abuot">About</Link> // ❌ typecheck failsDynamic segments work through ordinary template literals:
<Link href={`/posts/${post.slug}`}>{post.title}</Link> // ✅
<Link href={`/postz/${post.slug}`}>{post.title}</Link> // ❌There is no route() helper
Deliberately. A template literal is already checked the same way a builder would check it, so a builder would only wrap what the language does for free. One existed and was removed.
The one thing to watch is that a value you interpolate is url-safe.
`/posts/${'a / b'}` type-checks and means three path segments — use
encodeURIComponent when the value is not yours:
<Link href={`/posts/${encodeURIComponent(slug)}`}>…</Link>Search params, typed by the page
A page that exports a searchParams schema has said
what its query string means. The same schema types every link to it:
export const searchParams = z.object({
q: z.string().default(''),
page: z.coerce.number().int().min(1).default(1),
});<Link href="/search" search={{ q: 'shoes', page: 2 }}>Page 2</Link>
<Link href="/search" search={{ sort: 'asc' }}>…</Link> // does not compile: the page never reads sort
<Link href="/search" search={{ page: '2' }}>…</Link> // does not compile: page is a numberA key the page requires is required on the link — a q: z.string() with no
default makes search itself required, so the page’s error boundary is not
where a missing q is found. A page with no schema takes any scalars, and so
does an href that is not one route (path as Href), because there is nothing
to check it against.
Values are typed by what the page will see, not what the schema accepts:
z.coerce.number() takes anything in, and a link typed by that would take
page: 'two'. Required-ness comes from the schema’s input, the value from its
output. Arrays repeat the key — tags: ['a', 'b'] is ?tags=a&tags=b, which
is what z.array() parses back.
For visit(), prefetch() and anything else that wants the string,
href() runs the same check:
import { href } from '@rsc-kit/core/routes';
visit(href('/search', { q: 'shoes', page: 2 }));The build writes one line per route into rsc-routes.d.ts that reads the
page module’s searchParams export as a type. Nothing runs; a page without
the export costs nothing.
Two limits
A dynamic segment widens. /posts/[slug] becomes `/posts/${string}`,
so /posts/a/b type-checks even though it does not match at runtime.
A list widens to string unless you say what it is:
const nav = [
{ href: '/', label: 'Home' },
{ href: '/about', label: 'About' },
] satisfies { href: Href; label: string }[]Without satisfies, TypeScript infers string for href and you lose the
check.
If you never run the generator
.rsc-kit/rsc-routes.d.ts is written by the build. Without it — or with a
tsconfig whose include does not cover .rsc-kit — nothing is registered,
every url-taking prop stays exactly as permissive as a plain string, and
nothing breaks. There is no flag to turn this on.
redirect() is not typed
Its destination is usually computed — read from a cookie, handed over by middleware — so typing it would make the common case a cast.
Api routes
Every build writes the route.ts files it found as well, in their own union —
so a fetch to an endpoint that no longer exists stops compiling:
import { apiUrl } from '@rsc-kit/core/routes'
await fetch(apiUrl(`/api/orders/${id}`))
await fetch(apiUrl('/api/ordrs')) // does not compileapiUrl returns what it was given. It exists because fetch takes any
string, so without somewhere to put the type there is nothing to check
against — the function is the place.
Pages and api routes are separate unions on purpose. <Link href="/api/health">
does not compile, because linking to an api route navigates the browser away to
a json document; and apiUrl('/orders') does not compile either, because
fetching a page gets html where json was expected. Each refuses the other’s
urls, which is the pair of mistakes worth catching.