---
title: "Typed routes"
description: "Links that fail the typecheck instead of the browser."
---

> Documentation Index
> Fetch the complete documentation index at: https://docs.rsc-kit.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Typed routes

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

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

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

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

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

Source: https://docs.rsc-kit.dev/guides/typed-routes/index.mdx
