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

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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close