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 Route), 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: Route; label: string }[]Without satisfies, TypeScript infers string for href and you lose the
check.
The build checks
vite build runs the project’s typecheck before it bundles, and stops on an
error — so a link to a route that does not exist is a failed build, not a 404
found after deploying. It is the same tsc --noEmit the check script runs,
on the same tsconfig, and it costs about a second. rscKit({ typecheck: false })
turns it off; a project with no tsconfig.json, or no typescript
installed, is skipped without being asked.
After adding a route
The file is written whenever Vite starts — a build, or the dev server, which
also rewrites it when it sees a route added. A typecheck run with neither, by
an agent or in CI, reads what the last start wrote, so a link to a route
added since fails with "/admin" is not assignable to type Route.
rsc-kit-typegen writes it without starting Vite, from the app’s own
vite.config. The scaffold’s typecheck and check scripts run it first; an
older project adds it the same way:
"typecheck": "rsc-kit-typegen && tsc --noEmit"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 typed
redirect('/login') is checked like a Link, so a redirect to a page that was
renamed or deleted stops compiling instead of sending someone to a 404. A
destination that is computed — read from a cookie, handed over by middleware —
is a string the compiler cannot check, so it takes the cast:
redirect('/login'); // ✅
redirect('/logn'); // ❌
redirect(returnTo as Route); // computed: you vouch for itA page’s search is checked on redirect() too, against the destination’s own
searchParams schema — see redirects.
Api routes
A route.ts is a Route too — <Link href="/logout">, visit('/agent-account')
and redirect('/files/export.csv') all typecheck, as they do in Next. What
differs is what the browser does with one: a route answers with a Response,
not a page, so the client treats a link to it as the anchor it is — never
prefetched (a hover must not sign someone out) and a full navigation rather
than a payload fetch. The build hands the client every route’s pattern for
that.
Every build also writes the route.ts files in their own narrower union,
ApiRoute, 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.
apiUrl('/orders') does not compile, because fetching a page gets html where
json was expected — the one mistake the narrower union is for.
The backend’s urls
A prefix named in backendPaths is a Route too, so a link or a redirect to a
url the backend owns compiles as written:
// vite.config.ts: rscKit({ backendPaths: ['/gitlab'] })
<Link href="/gitlab/connect">Connect GitLab</Link> // ✅
redirect('/gitlab/connect'); // ✅
<Link href="/gitlabs">…</Link> // ❌ not the prefixThe prefix is a segment boundary, as it is for the server: /gitlab, anything
under it, and a query or fragment, never /gitlabs. What is behind it is the
backend’s, so nothing past the prefix is checked. Like a route.ts, the browser
loads it as a document and never prefetches it — see leaving the
app.
Regions
revalidate() in an action and refresh() in the browser take a region by
name, and the names are typed to the ones the build found: every
section('orders', …) and every @slot directory, plus 'page' and
'all'. A typo, or a section renamed since, stops compiling instead of being
refused by the renderer at runtime:
revalidate('orders') // a section the build found
revalidate('order') // does not compileA name computed at runtime is cast, as an href is: revalidate(name as RevalidateTarget). A name that reaches the renderer unknown anyway is still
refused with the names it does know.