# Typed routes

> Links that fail the typecheck instead of the browser.

Every build writes the urls it found to `.rsc-kit/rsc-routes.d.ts`.
`Link`, `visit`, `prefetch` and `Form` accept only those.

```tsx
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:

```tsx
<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:

```tsx
<Link href={`/posts/${encodeURIComponent(slug)}`}>…</Link>
```

## Search params, typed by the page

A page that [exports a `searchParams` schema](/guides/url-validation) has said
what its query string means. The same schema types every link to it:

```tsx title="src/app/search/page.tsx"
export const searchParams = z.object({
  q: z.string().default(''),
  page: z.coerce.number().int().min(1).default(1),
});
```

```tsx
<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:

```ts
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:

```tsx
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:

```json title="package.json"
"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 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

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:

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

:::note[Paths, not response types]
This checks the **url**. It does not infer what the endpoint returns — that
would mean a typed `json()` helper of our own in place of `Response.json()`, and
api routes are deliberately web standards with nothing of ours required in them.

For end-to-end types without a fetch at all, a [server action or
query](/guides/queries/) is already typed across the boundary: the return type
is the function's, because it is the same function.
:::

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

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