@rsc-kit/mcp 0.18.1 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -42,6 +42,21 @@ CMD ["bun", ".output/server/index.mjs"]
42
42
  The second stage carries `.output` and nothing else. On the docs application
43
43
  that is 1.0 MB against 104 MB of `node_modules`.
44
44
 
45
+ <Aside type="tip" title="What a port measured">
46
+ A production app ported from Next, same pages, same backend, the Bun binary
47
+ image beside the Next one it replaced:
48
+
49
+ | | Next | rsc-kit |
50
+ | --- | --- | --- |
51
+ | docker image | 104.56 MiB | 50.12 MiB |
52
+ | docker build | 5 m 13 s | 2 m 45 s |
53
+ | Lighthouse | — | 99 mobile, 100 desktop |
54
+
55
+ Nothing was tuned for the numbers. The image is smaller because the bundle
56
+ carries no `node_modules`; the build is faster because there is one build,
57
+ not a build and a trace.
58
+ </Aside>
59
+
45
60
  ## Or a platform, without a Dockerfile
46
61
 
47
62
  Change the preset and Nitro produces what that platform expects — a Worker and
@@ -124,6 +139,16 @@ they are covered. See [serving shells from a CDN](/guides/edge-caching).
124
139
 
125
140
  Cached responses carry a build version, so a deploy invalidates them.
126
141
 
142
+ **Build, then start — never build under a running server.** The server loads
143
+ its rsc and ssr services lazily, on the first request that needs each, and
144
+ a `vite build` that is rewriting `.output/` at that moment hands it a file
145
+ that is half-written or briefly missing. The runtime caches that failed
146
+ import — Bun does, and so does Node's ESM loader — so the route answers 500
147
+ with an `ENOENT` for a file that now exists, and keeps doing so until the
148
+ process restarts. In development use `vite` (the dev server), which owns its
149
+ own rebuilds; in production build into a fresh directory or stop the server
150
+ first, and start it against a finished `.output/`.
151
+
127
152
  Ship `.output/` from the same commit as the code that serves it. A server
128
153
  running one build against another's frozen pages is the one combination nothing
129
154
  checks for you.
package/guides/emails.md CHANGED
@@ -87,4 +87,16 @@ src/lib/nodemailer.ts. Put the rendering — the template and the call — in a
87
87
  module that starts with "use ssr" …
88
88
  ```
89
89
 
90
- A build prints the same once, as a warning, naming the file.
90
+ A build prints the same once, as a warning, naming the file — and the
91
+ build still succeeds, because the refusal is the call's, not the import's:
92
+ every export of the stub throws it when called, so a server does not fail to
93
+ boot over an action nobody has run yet.
94
+
95
+ A dependency whose imports the build never looks inside — `@react-email/render`
96
+ imported from an action, say — is read once for the import and warned about
97
+ the same way, naming the app file and the package:
98
+
99
+ ```
100
+ src/actions/send-otp.ts imports @react-email/render, which imports
101
+ react-dom/server, and that cannot run where server components render …
102
+ ```
@@ -0,0 +1,63 @@
1
+ # Feature flags
2
+
3
+ > Vercel's Flags SDK runs unchanged — flags/next, without Next.
4
+
5
+ Nothing to install from here. The [Flags SDK](https://flags-sdk.dev) is
6
+ framework-agnostic at its core, and its Next integration — `flags/next`,
7
+ the one with `flag()`, `dedupe()` and the adapters — needs one thing from
8
+ Next: `next/headers`, for `headers()` and `cookies()`. Those exist here
9
+ under the same names and with the same shapes, one object per request,
10
+ which is what the SDK's per-request dedupe keys on. The build answers
11
+ `next/headers` with them, so the SDK runs as written:
12
+
13
+ ```sh
14
+ bun add flags
15
+ ```
16
+
17
+ ```ts title="src/flags.ts"
18
+ import { flag, dedupe } from 'flags/next'
19
+
20
+ const visitor = dedupe(async ({ cookies, headers }) => ({
21
+ id: cookies.get('visitor')?.value ?? 'anonymous',
22
+ country: headers.get('x-vercel-ip-country') ?? headers.get('cf-ipcountry') ?? '',
23
+ }))
24
+
25
+ export const showBanner = flag<boolean, { id: string; country: string }>({
26
+ key: 'show-banner',
27
+ identify: visitor,
28
+ decide: ({ entities }) => entities?.country === 'GB',
29
+ })
30
+ ```
31
+
32
+ ```tsx title="src/app/page.tsx"
33
+ import { showBanner } from '../flags'
34
+
35
+ export default async function Home() {
36
+ const banner = await showBanner()
37
+
38
+ return banner ? <Banner /> : null
39
+ }
40
+ ```
41
+
42
+ A flag reads the request, so the page that awaits one renders per visitor —
43
+ the build says `headers() in run, cookies() in run stream per request` and
44
+ stores the rest as a shell, given a `<Suspense>` or a `loading.tsx` above
45
+ the read. That is the same line any `headers()` call earns; see
46
+ [Static generation](/guides/static-generation). An adapter — Statsig,
47
+ LaunchDarkly, Vercel's own — is the SDK's, configured the way its docs say.
48
+
49
+ The discovery endpoint is a route handler in the shape a `route.ts` already
50
+ has:
51
+
52
+ ```ts title="src/app/.well-known/vercel/flags/route.ts"
53
+ import { createFlagsDiscoveryEndpoint, getProviderData } from 'flags/next'
54
+ import * as flags from '../../../../flags'
55
+
56
+ export const GET = createFlagsDiscoveryEndpoint(async () => getProviderData(flags))
57
+ export const openapi = false
58
+ ```
59
+
60
+ What does not carry over is `precompute()`: it is built on a Next
61
+ middleware rewriting the url to a permutation, which is Next's routing.
62
+ Read the flag in the page instead; a flag read under a boundary costs one
63
+ streamed hole, not the page.
package/guides/fonts.md CHANGED
@@ -110,6 +110,31 @@ export default function RootLayout({ children }) {
110
110
  the one file the first paint needs, usually the Latin regular; preloading all
111
111
  of them defeats the subsetting.
112
112
 
113
+ ## A font never blocks the page
114
+
115
+ Text paints before the web font arrives, always. That is not a tuning
116
+ choice; it is the rule everything below serves, and a font can break it in
117
+ three ways, each of which the setup above has already closed:
118
+
119
+ - **A stylesheet from another origin.** `<link href="https://fonts.googleapis.com/…">`
120
+ is render-blocking CSS from a host the browser has not connected to: a DNS
121
+ lookup, a TLS handshake and a round trip before the first paint, on every
122
+ cold load. Self-hosting through Fontsource keeps the `@font-face` rules in
123
+ your own stylesheet, which is inlined into the document.
124
+ - **`font-display: block`, or none at all.** Without a `font-display`, the
125
+ browser is free to hide text for up to three seconds while it waits — the
126
+ invisible-text flash. Every rule the setup writes says `swap` (paint the
127
+ fallback now, swap when the font lands) or `optional` (paint once, in
128
+ whichever is ready). Never `block`, and never leave it unset in a rule you
129
+ own.
130
+ - **Preloading everything.** A `<link rel="preload">` per file puts every
131
+ weight and subset ahead of the page in the network queue. Preload the one
132
+ file the first paint needs; the rest arrive with the stylesheet.
133
+
134
+ A font that is slow — a cold cache, a throttled connection — then costs a
135
+ swap or a fallback, never a blank page. The section below is how to make
136
+ that swap invisible.
137
+
113
138
  ## Getting to 100 on a phone
114
139
 
115
140
  Fontsource's stylesheet is the right default and the wrong last mile. It
package/guides/forms.md CHANGED
@@ -7,6 +7,21 @@ that handles the state for you, and a hook for when you want to hold it
7
7
  yourself. Both cover validation errors, pending state, optimistic updates and
8
8
  GET-form navigation.
9
9
 
10
+ ## The rule: uncontrolled, unless one field needs otherwise
11
+
12
+ A form here is **uncontrolled by default**. Inputs keep their own value in
13
+ the DOM, an initial value is React's `defaultValue`, and the action reads
14
+ `FormData` on submit. No `useState` per field, no `value`/`onChange` pair,
15
+ no re-render of the whole form on every keystroke — and after a refused
16
+ submit the values are still there, because nothing re-rendered the inputs.
17
+
18
+ Reach for a controlled field only where the UI has to react *as* the user
19
+ types — a character count, a live preview, a dependent select — and bind
20
+ that one field with `useField` (below), which scopes the re-render to it.
21
+ Coming from react-hook-form this is the same default; coming from TanStack
22
+ Form or from `useState`-per-input it is the opposite, and the difference is
23
+ most of why these forms stay fast.
24
+
10
25
  ## The `<Form>` component
11
26
 
12
27
  The simplest way to handle forms. Works without any hooks — just pass a server action and use the render-prop for pending state and errors.
@@ -275,6 +290,54 @@ genuinely reloads. Then the server renders the page again, and putting the
275
290
  values back is the server's job — return them from the action and render them
276
291
  as `defaultValue`.
277
292
 
293
+ ### The schema is written for the shape it wants
294
+
295
+ Every value in a `FormData` is a string or a file, and a control that is off
296
+ is not there at all — a fact that used to leak into every schema as
297
+ `z.coerce.number()` and a per-checkbox `.transform()`. It does not any more.
298
+ The form is read *the way the schema means it*, on both sides:
299
+
300
+ ```ts
301
+ const settings = z.object({
302
+ notify: z.boolean(), // unchecked posts nothing → false; "on" → true
303
+ limit: z.number().int().min(1), // "5" → 5
304
+ tags: z.array(z.string()), // one tag → ['a']; none ticked → []
305
+ policy: z.string().optional(), // hidden behind a switch → absent when off
306
+ auth: z.discriminatedUnion('kind', [ // auth[kind] picks the branch
307
+ z.object({ kind: z.literal('none') }),
308
+ z.object({ kind: z.literal('bearer'), token: z.string().min(1) }),
309
+ ]),
310
+ rules: z.array(z.object({ on: z.boolean(), max: z.number() })), // rules[0][on], rules[0][max]
311
+ }).refine((s) => !s.notify || (s.policy ?? '').length > 0, {
312
+ path: ['policy'],
313
+ message: 'Say what to notify about.',
314
+ })
315
+ ```
316
+
317
+ Nothing in that schema knows it will meet a form. A schema describes itself
318
+ (Standard JSON Schema — Zod 4 and ArkType do; Valibot not yet, and its
319
+ values arrive as strings), and the decoder coerces to it: an absent
320
+ `boolean` is `false`, `"on"`/`"1"` is `true`; a numeric string for a
321
+ `number` is the number, an empty one for an optional number is absent; an
322
+ `array` given one value is a list of one and given nothing is empty; nested
323
+ names nest (`fields[0][name]` and `fields[0].name` alike); a union takes the
324
+ branch its discriminator names; a control left blank is absent for a field
325
+ the schema does not require, whatever its type — `z.email().optional()`
326
+ accepts the empty input, an optional union is not read as its first branch
327
+ — and is `""` for one it requires, so `z.string().min(1)` can say so. A value the schema refuses is still refused as itself — `"many"` for a
328
+ number is the error you expect.
329
+
330
+ A leaf JSON Schema cannot describe — `z.date()`, a custom check — costs only
331
+ that leaf, which arrives as posted; the fields beside it are still read the
332
+ way the schema means. A schema that cannot describe itself at all is said
333
+ once, in development, with the library's reason.
334
+
335
+ `<Form>` validates that object in the browser and the action decodes the
336
+ same object on the server, from one codec, so a form that passes here passes
337
+ there. A value sent through `<Form transform>` is encoded the same way back
338
+ — a boolean as `"1"`/`"0"`, a nested object as `key[prop]` — and decodes to
339
+ what was given.
340
+
278
341
  ### Lists of values
279
342
 
280
343
  A repeated name is an array:
@@ -405,6 +468,28 @@ and fades. It is state rather than a timer in every form that wants one,
405
468
  because the timer has to be cleared when the component goes away and that is
406
469
  the part people forget.
407
470
 
471
+ ### Nothing to save yet
472
+
473
+ ```tsx
474
+ {({ dirty, reset }) => (
475
+ <>
476
+ <Button type="submit" disabled={!dirty}>Save</Button>
477
+ {dirty && <Button type="button" variant="ghost" onClick={reset}>Discard</Button>}
478
+ </>
479
+ )}
480
+ ```
481
+
482
+ `dirty` is whether anything differs from what the form started with. It is
483
+ read from the form itself — a snapshot of its `FormData` on mount, compared
484
+ on every input — so an uncontrolled field counts, which is the reason it is
485
+ the form's to answer and not something a component beside it could work
486
+ out. A successful submit makes the current values the new baseline;
487
+ `reset()` goes back to the first one. A bound control with no native
488
+ element behind it counts too, through the store. Only a form that reads
489
+ `dirty` is measured, and it renders once when the value first differs — not
490
+ on the keystrokes after — so a field that subscribes for itself keeps its
491
+ promise of rendering alone.
492
+
408
493
  ### Why not a `<Field>` component
409
494
 
410
495
  TanStack Form and react-hook-form both hand you a field through a render prop —
@@ -519,6 +604,24 @@ the markup is submittable on its own. Someone who hits enter before the
519
604
  javascript arrives still reaches the server; the page reloads with the result
520
605
  instead of updating in place.
521
606
 
607
+ What happens on the wire: React writes the action's id into the form as a
608
+ hidden field and points the form at the page's own url, and the browser
609
+ posts there. The host reads the fields, runs the action they name exactly as
610
+ the enhanced path would have called it, and renders the page again with what
611
+ it returned seated in the form that posted — so a refusal shows on its
612
+ fields, `error('email')` and `fieldState` included, for a visitor with no
613
+ javascript at all. A `redirect()` the action throws is followed as a
614
+ document's would be; a cookie it sets is on the answer. Same origin only, as
615
+ an action is, and the answer is never stored. A stored page is the one most
616
+ likely to be submitted this way — it paints before its runtime arrives —
617
+ which is why this is not optional.
618
+
619
+ The seating is React's own `useActionState`, which `<Form>` uses under a
620
+ wrapper bound to your action: React hands a form-state action a
621
+ `(previousState, formData)` pair, and your action keeps taking the
622
+ `FormData` alone. A `useActionState` of your own works the same way, with
623
+ an action written for the pair.
624
+
522
625
  The two do not fight. The handler calls `preventDefault()` first, and React does
523
626
  not run a form action for a submit that was cancelled — so the enhanced path
524
627
  wins whenever there is one, and the native path is what is left when there is
package/guides/index.json CHANGED
@@ -59,6 +59,11 @@
59
59
  "title": "Errors and 404s",
60
60
  "description": "What a visitor sees when a page throws, or asks for a url nothing answers."
61
61
  },
62
+ {
63
+ "slug": "feature-flags",
64
+ "title": "Feature flags",
65
+ "description": "Vercel's Flags SDK runs unchanged — flags/next, without Next."
66
+ },
62
67
  {
63
68
  "slug": "file-uploads",
64
69
  "title": "File uploads",
@@ -134,6 +139,11 @@
134
139
  "title": "Offline",
135
140
  "description": "Knowing when the server cannot be reached, and carrying on without it."
136
141
  },
142
+ {
143
+ "slug": "openapi",
144
+ "title": "OpenAPI",
145
+ "description": "A document derived from your route.ts files, and Scalar's page over it."
146
+ },
137
147
  {
138
148
  "slug": "ppr",
139
149
  "title": "Partial prerendering",
@@ -63,6 +63,7 @@ export default defineConfig({
63
63
  sourceDir: "src",
64
64
  outDir: "build",
65
65
  offline: true,
66
+ openapi: { info: { title: 'Example API', version: '1.0.0' } },
66
67
  }),
67
68
  react(),
68
69
  ],
@@ -171,7 +172,7 @@ runs](/hosts/where-it-runs).
171
172
  "dev": "bun --bun vite",
172
173
  "build": "bun --bun vite build",
173
174
  "start": "bun .output/server/index.mjs",
174
- "compile": "bun --bun vite build && bun build --compile .output/server/index.mjs --outfile dist/app"
175
+ "compile": "bun --bun vite build && bun build --compile .output/server/compile.mjs --outfile dist/app"
175
176
  }
176
177
  }
177
178
  ```
@@ -259,12 +260,12 @@ for before they will run.
259
260
  There is nothing to install and nothing this package adds — Vite already owns
260
261
  this. Two rules and one declaration.
261
262
 
262
- Anything named `VITE_*` is **inlined into the client bundle** and ships to the
263
- browser, whether or not a browser file reads it. Everything else stays on the
264
- server, read through `process.env`.
263
+ Anything named `PUBLIC_*` (or Vite's own `VITE_*`) is **inlined into the
264
+ client bundle** and ships to the browser, whether or not a browser file reads
265
+ it. Everything else stays on the server, read through `process.env`.
265
266
 
266
267
  The prefix is the whole boundary, so never put a secret behind it.
267
- `VITE_STRIPE_KEY` is a published key.
268
+ `PUBLIC_STRIPE_KEY` is a published key.
268
269
 
269
270
  And one line never to write into a `.env`: `NODE_ENV`. Vite sets it itself —
270
271
  `development` under `vite`, `production` under `vite build` — and honours a
@@ -287,7 +288,7 @@ interface ViteTypeOptions {
287
288
  }
288
289
 
289
290
  interface ImportMetaEnv {
290
- readonly VITE_API_URL: string;
291
+ readonly PUBLIC_API_URL: string;
291
292
  }
292
293
  ```
293
294
 
@@ -299,7 +300,7 @@ interface ImportMetaEnv {
299
300
 
300
301
  #### When they are read
301
302
 
302
- `import.meta.env.VITE_*` is a literal in the bundle: changing one means a
303
+ `import.meta.env.PUBLIC_*` is a literal in the bundle: changing one means a
303
304
  rebuild. `process.env.*` is a live read, so the deploy's environment wins —
304
305
  with one exception the build makes for you. A route that reads an environment
305
306
  variable and nothing request-bound is frozen at build time, value included,
@@ -321,13 +322,18 @@ import { z } from 'zod';
321
322
 
322
323
  export const env = createEnv({
323
324
  server: { DATABASE_URL: z.string().url() },
324
- clientPrefix: 'VITE_',
325
- client: { VITE_API_URL: z.string().url() },
325
+ clientPrefix: 'PUBLIC_',
326
+ client: { PUBLIC_API_URL: z.string().url() },
326
327
  runtimeEnv: typeof process === 'undefined' ? import.meta.env : { ...import.meta.env, ...process.env },
327
328
  emptyStringAsUndefined: true,
328
329
  });
329
330
  ```
330
331
 
332
+ `typeof process === 'undefined'` is not decoration: a `"use client"` file
333
+ that imports this for a `PUBLIC_` value runs where there is no `process`,
334
+ and `{ ...process.env }` there is a `ReferenceError` before the first
335
+ render. The scaffold's `env.ts` is written this way.
336
+
331
337
  Read `env.DATABASE_URL` instead of `process.env.DATABASE_URL` and three things
332
338
  follow. It is a `string`, not `string | undefined`. A server variable touched
333
339
  from a client component throws by name rather than being silently `undefined`.
package/guides/laravel.md CHANGED
@@ -127,6 +127,16 @@ already use. Vite **is** the renderer in development. It writes
127
127
  Laravel does not route is handed through to the address inside it. Stop the
128
128
  dev server and the file goes with it, and so does the proxy.
129
129
 
130
+ Per url the rule is: **if the React tree has it, React renders it**;
131
+ otherwise Laravel does. That holds for a url Laravel also routes — a fresh
132
+ application ships `Route::get('/', …)` to its welcome page, and after
133
+ install `/` is `resources/js/app/page.tsx`, not the welcome page. The build
134
+ writes its route table to `bootstrap/rsc/vite/routes.json` on every `vite`
135
+ and `vite build`, and the package registers those urls after `routes/web.php`
136
+ loads, so a page added to the tree is routed on the next request. The
137
+ welcome route can stay or go; it answers nothing while the page exists.
138
+ Until the first `vite` there is no table, and Laravel answers everything.
139
+
130
140
  The other direction works too. The renderer reads `APP_URL` and
131
141
  `RSC_HOST_CALL_SECRET` from the app's own `.env`, and a url the route tree does
132
142
  not own — `/login`, a Blade page, a webhook, a file under `/storage` — is
@@ -232,7 +242,21 @@ method runs, which is where `ValidationException` usually comes from.
232
242
 
233
243
  A class under `app/Rsc/Actions/` is a server action. `rsc:action-manifest`
234
244
  writes the map, and the build writes a `"use server"` module beside your pages
235
- exporting one function per method, named `classMethod`:
245
+ exporting one function per method, named `classMethod`.
246
+
247
+ Make one with its guards already on it:
248
+
249
+ ```sh
250
+ php artisan make:rsc-action Orders --method=cancel --auth --can=update,Order --revalidate=orders
251
+ ```
252
+
253
+ `--method` per call (none makes the class invokable, reached as `orders`),
254
+ `--auth` for `#[Authenticated]`, `--can=ability` or `--can=ability,Model` for
255
+ `#[Can]`, `--middleware=throttle:60,1` for `#[Middleware]`, `--revalidate`
256
+ for the `Rsc::revalidate()` line, and `--rpc` to make a class for `rpc()`
257
+ under `app/Rsc` instead. A slash nests: `Billing/Invoices`. The attributes it
258
+ writes are the ones the registry reads, so what you asked for at the prompt
259
+ is what runs.
236
260
 
237
261
  ```php title="app/Rsc/Actions/Orders.php"
238
262
  namespace App\Rsc\Actions;
@@ -362,7 +386,8 @@ closer to a millisecond. What matters is how many *sequential* calls a page
362
386
  needs. A page with no `middleware.ts` middleware makes no guard call; sibling
363
387
  components awaiting `rpc()` are rendered concurrently, so their calls
364
388
  overlap — and calls issued in the same tick travel as **one** request, a
365
- batch the package answers in one Laravel request; `cache()` dedupes
389
+ batch the package answers in one Laravel request, one line per call as
390
+ each finishes, so a fast read is not held behind a slow one; `cache()` dedupes
366
391
  identical calls within a request; a frozen page makes none at all and a
367
392
  shell only for its holes. A guarded page is therefore typically two Laravel
368
393
  requests — the guard, then the batch of its reads — and a host-call-heavy app
package/guides/offline.md CHANGED
@@ -65,8 +65,10 @@ rscKit({ offline: true })
65
65
  ```
66
66
 
67
67
  The build writes `sw.js` beside the assets and the generated entry registers
68
- it. With it on, a page you have visited survives a full reload with no network
69
- at all not just a navigation, a reload and comes back interactive.
68
+ it once the page has loaded, or at once if it already has by the time the
69
+ runtime boots. With it on, a page you have visited survives a full reload
70
+ with no network at all — not just a navigation, a reload — and comes back
71
+ interactive.
70
72
 
71
73
  Everything else lives in one page's memory — the pages a boundary keeps
72
74
  mounted, the prefetch cache. Reload with no network and the browser shows its
@@ -86,7 +88,8 @@ on every navigation, so it happens on the first load.
86
88
 
87
89
  | | |
88
90
  | --- | --- |
89
- | hashed assets | at install, and answered from the cache forever after the name changes when the bytes do |
91
+ | what boots the app | at install: the scripts, stylesheets, fonts, manifest and icons, plus `/` and the offline page with the payloads they boot from. Not an image, a wasm module or the share card those are cached the first time they are used, so an install costs what a first page costs and not a megabyte more |
92
+ | any other hashed asset | the first time it is asked for, and from the cache forever after — the name changes when the bytes do |
90
93
  | a page you loaded | its document, and the payload it boots from |
91
94
  | a page you reached by link | its payload, and its document fetched once to go with it |
92
95
  | a page you never visited | nothing |
@@ -178,7 +181,9 @@ export default function Offline() {
178
181
  Nothing in the file makes it special. It is an ordinary route, and what makes it
179
182
  the fallback is that the build stored it and the worker precached it. It is also
180
183
  the one page that can honestly stand in for another, because it is about being
181
- offline rather than about the url it appears under.
184
+ offline rather than about the url it appears under. It stands in for every
185
+ navigation nothing can answer — a page the build stored but this browser
186
+ never visited included.
182
187
 
183
188
  **It has to be static.** A fallback that renders per request cannot be served
184
189
  when there is no request to be made, so the build checks and says when it will
@@ -0,0 +1,99 @@
1
+ # OpenAPI
2
+
3
+ > A document derived from your route.ts files, and Scalar's page over it.
4
+
5
+ Every `route.ts` already says what an OpenAPI operation needs: the methods it
6
+ exports, and the `params`, `searchParams` and `body` schemas beside them. So
7
+ the document is derived, not written — the way Elysia derives its from the
8
+ schemas on its routes — and it cannot go stale.
9
+
10
+ ```ts title="vite.config.ts"
11
+ rscKit({
12
+ openapi: {
13
+ info: { title: 'Shop API', version: '1.0.0' },
14
+ servers: [{ url: 'https://api.shop.example' }],
15
+ security: [{ bearerAuth: [] }],
16
+ components: { securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer' } } },
17
+ },
18
+ })
19
+ ```
20
+
21
+ `openapi: true` is the same with defaults. The document answers at
22
+ `/openapi.json` (`path` moves it), as an api route the build stores, without
23
+ middleware — a document exists to be read.
24
+
25
+ ## What a route contributes
26
+
27
+ ```ts title="src/app/api/orders/[id]/route.ts"
28
+ import { z } from 'zod'
29
+ import type { RouteContext } from '@rsc-kit/core/route-schema'
30
+
31
+ export const params = z.object({ id: z.coerce.number().int() })
32
+ export const searchParams = z.object({ expand: z.boolean().optional() })
33
+ export const body = z.object({ title: z.string().min(1) })
34
+
35
+ export const openapi = {
36
+ tags: ['Orders'],
37
+ PATCH: { summary: 'Rename an order' },
38
+ responses: { 200: { description: 'The order' } },
39
+ }
40
+
41
+ export async function GET(request: Request, { params }: RouteContext<typeof params>) { … }
42
+ export async function PATCH(request: Request, { params, body }: RouteContext<typeof params, never, typeof body>) { … }
43
+ ```
44
+
45
+ | from | into the document |
46
+ | --- | --- |
47
+ | the directory | the path: `/api/orders/[id]` → `/api/orders/{id}` |
48
+ | each method export | an operation |
49
+ | `params` | the path parameters' types; a segment with no schema is a string |
50
+ | `searchParams` | one query parameter per property, required where the schema requires it |
51
+ | `body` | the request body of `POST`, `PUT`, `PATCH`, `DELETE`, and a documented `422` |
52
+ | a `middleware.ts` above | a `session` security requirement, and `401`/`403` |
53
+ | `export const openapi` | anything else an operation may say — `summary`, `description`, `tags`, `responses` — shared, or per method under `GET`/`POST`/… |
54
+
55
+ A schema contributes by describing itself as JSON Schema (Standard JSON
56
+ Schema): Zod 4 and ArkType do; Valibot needs its own converter and
57
+ contributes nothing yet. Response bodies are what a route declares in
58
+ `openapi.responses` — a handler returns `Response`, so nothing else knows the
59
+ shape — until a typed response helper carries it.
60
+
61
+ `export const openapi = false` leaves a route out: the page that renders the
62
+ document, a webhook meant for one caller. `{ DELETE: false }` leaves one
63
+ method out. An app whose routes are mostly webhooks turns the default
64
+ around with `rscKit({ openapi: { include: 'declared' } })`: only a route
65
+ that exports `openapi` is documented, and a callback needs no line. `HEAD` and `OPTIONS` are never documented — the engine answers
66
+ them for every route, and a file exporting `OPTIONS` for a CORS preflight is
67
+ not describing an operation.
68
+
69
+ ## The page
70
+
71
+ Scalar's API Reference is a route handler in this shape already, so the
72
+ page is one file and none of it is ours:
73
+
74
+ ```sh
75
+ bun add @scalar/nextjs-api-reference
76
+ ```
77
+
78
+ ```ts title="src/app/reference/route.ts"
79
+ import { ApiReference } from '@scalar/nextjs-api-reference'
80
+
81
+ export const GET = ApiReference({ url: '/openapi.json' })
82
+ export const openapi = false
83
+ ```
84
+
85
+ Stored at build like any route that reads nothing per request. Scalar's
86
+ package is the app's dependency and the app's version; the engine ships no
87
+ UI and no dependency for it, whether or not the document is on.
88
+
89
+ ## Coming from a hand-written spec
90
+
91
+ A spec kept in a file has three parts, and two of them move:
92
+
93
+ - the `paths` — delete them; they are the routes and their schemas now, and
94
+ the same `body` schema validates the request at runtime, which the
95
+ hand-written spec never did
96
+ - `info`, `servers`, `security`, `components.securitySchemes` — into
97
+ `rscKit({ openapi })`
98
+ - a route's `summary`, `tags` and response shapes — into its
99
+ `export const openapi`
@@ -29,7 +29,9 @@ export const env = createEnv({
29
29
  },
30
30
  clientPrefix: 'PUBLIC_',
31
31
  client: {},
32
- runtimeEnv: { ...process.env, ...import.meta.env },
32
+ // No bare process.env: a "use client" file importing this for a PUBLIC_
33
+ // value has no process, and the spread would throw before the first render.
34
+ runtimeEnv: { ...(typeof process === 'undefined' ? {} : process.env), ...import.meta.env },
33
35
  emptyStringAsUndefined: true,
34
36
  })
35
37
  ```
@@ -55,7 +55,11 @@ windows, and nothing you write chooses between them:
55
55
  Neither buffers the response. Before anything is written the host is still
56
56
  waiting on the shell, so a component that redirects instead of rendering is
57
57
  caught there. After that, React already carries an error digest to the client
58
- and the destination rides along in it.
58
+ and the destination rides along in it, and the browser performs it as a
59
+ navigation — the layouts above stay mounted, the url being left is replaced
60
+ rather than kept in history. An `error.tsx` on the route never sees it: a
61
+ redirect is the page's answer, not a failure, whether it is thrown by the
62
+ page, by a component under its own `<Suspense>`, or by a parallel route slot.
59
63
 
60
64
  <Aside type="caution" title="Middleware belongs above the boundaries">
61
65
  A `loading.tsx` wraps the whole page in `<Suspense>`. That is usually what you
@@ -85,10 +89,10 @@ renders, on every path. This is the one built for the job.
85
89
  layout skips the check:
86
90
 
87
91
  ```bash
88
- curl -H 'X-RSC: true' -H 'X-RSC-Segments: app/layout' /guarded
92
+ curl -H 'X-RSC: 1' -H 'X-RSC-Segments: app/layout' /guarded
89
93
  # 204, X-RSC-Redirect: /orders ← the middleware ran
90
94
 
91
- curl -H 'X-RSC: true' -H 'X-RSC-Segments: app/layout,app/guarded/layout' /guarded
95
+ curl -H 'X-RSC: 1' -H 'X-RSC-Segments: app/layout,app/guarded/layout' /guarded
92
96
  # 200, and the page's content ← it did not
93
97
  ```
94
98
 
@@ -123,7 +127,18 @@ window, and nothing warns.
123
127
  ## From a server action
124
128
 
125
129
  An action is not a render, so there is no shell to be on either side of. Throw
126
- from the action and the client follows it:
130
+ from the action a plain `"use server"` function or one built from
131
+ `createActionClient()` — and the client follows it as a navigation: the
132
+ layouts stay mounted and the history entry the form was on is replaced, so
133
+ Back does not return to the submitted form. A `<Form>` shows nothing for it;
134
+ signing in and arriving is the success.
135
+
136
+ To the caller, the action **resolves** — with `{ redirected: '/where' }` —
137
+ once the navigation is under way. It does not throw: a `startTransition(async
138
+ () => { await logOut() })` with no `catch` around it would otherwise reject
139
+ into React, which unmounts the root, and a logout ended on a white page.
140
+ There is nothing to catch; a component that awaited the action is on its
141
+ way off the screen, and the object it got is safe to read any field of.
127
142
 
128
143
  ```ts title="src/actions.ts"
129
144
  'use server'
package/guides/routing.md CHANGED
@@ -232,14 +232,14 @@ fails at the list rather than at the `Link` that renders it:
232
232
 
233
233
  ```tsx title="src/components/Nav.tsx"
234
234
  // `satisfies` rather than a type annotation: an annotation would widen href to
235
- // Href and lose which one each entry is, while this keeps the literals and
235
+ // Route and lose which one each entry is, while this keeps the literals and
236
236
  // still checks them — so a typo fails here, at the list, rather than at the
237
237
  // Link that renders it.
238
238
  const links = [
239
239
  { href: '/', label: 'Home' },
240
240
  { href: '/dashboard', label: 'Dashboard' },
241
241
  { href: '/posts/hello-world', label: 'A Post' },
242
- ] satisfies { href: Href; label: string }[]
242
+ ] satisfies { href: Route; label: string }[]
243
243
  ```
244
244
 
245
245
  ### When the value is not already a url
@@ -269,9 +269,9 @@ When a destination is computed rather than written, cast it — that is the seam
269
269
  where you are telling the typechecker something it cannot know:
270
270
 
271
271
  ```ts
272
- import type { Href } from '@rsc-kit/core/routes';
272
+ import type { Route } from '@rsc-kit/core/routes';
273
273
 
274
- <Link href={savedPath as Href}>Resume</Link>
274
+ <Link href={savedPath as Route}>Resume</Link>
275
275
  ```
276
276
 
277
277
  Two limits. A dynamic segment widens to `${string}`, and a template literal type