Skip to content

Typed routes

Links that fail the typecheck instead of the browser.

Updated View as Markdown

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 fails

Dynamic 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:

src/app/search/page.tsxtsx
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 number

A 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:

package.jsonjson
"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 it

A 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 compile

apiUrl 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 prefix

The 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 compile

A 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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close