UNPKG

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