@rsc-kit/mcp
Version:
An MCP server over what an rsc-kit build decided: the routes, why each one is static or not, and what it costs the browser.
186 lines (137 loc) • 6.96 kB
Markdown
# 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.