@rsc-kit/mcp 0.14.0 → 0.15.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.
Files changed (47) hide show
  1. package/dist/answers.d.ts +7 -0
  2. package/dist/answers.js +26 -0
  3. package/dist/answers.js.map +1 -1
  4. package/dist/bundleGuides.d.ts +22 -0
  5. package/dist/bundleGuides.js +130 -0
  6. package/dist/bundleGuides.js.map +1 -0
  7. package/dist/index.js +21 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/recipes.js +81 -6
  10. package/dist/recipes.js.map +1 -1
  11. package/dist/report.d.ts +11 -0
  12. package/dist/report.js +1 -1
  13. package/dist/report.js.map +1 -1
  14. package/guides/api-routes.md +168 -0
  15. package/guides/authorization.md +288 -0
  16. package/guides/caching.md +57 -0
  17. package/guides/connection.md +98 -0
  18. package/guides/edge-caching.md +159 -0
  19. package/guides/errors.md +109 -0
  20. package/guides/file-uploads.md +119 -0
  21. package/guides/fonts.md +117 -0
  22. package/guides/forms.md +528 -0
  23. package/guides/images.md +83 -0
  24. package/guides/index.json +162 -0
  25. package/guides/mcp.md +113 -0
  26. package/guides/metadata.md +289 -0
  27. package/guides/navigation.md +84 -0
  28. package/guides/no-javascript.md +39 -0
  29. package/guides/offline.md +215 -0
  30. package/guides/ppr.md +181 -0
  31. package/guides/pwa.md +260 -0
  32. package/guides/queries.md +340 -0
  33. package/guides/react-compiler.md +153 -0
  34. package/guides/redirects.md +143 -0
  35. package/guides/response-headers.md +66 -0
  36. package/guides/route-interception.md +206 -0
  37. package/guides/routing.md +458 -0
  38. package/guides/sections.md +74 -0
  39. package/guides/server-actions.md +444 -0
  40. package/guides/static-generation.md +347 -0
  41. package/guides/testing.md +158 -0
  42. package/guides/third-party-scripts.md +105 -0
  43. package/guides/typed-routes.md +139 -0
  44. package/guides/url-validation.md +143 -0
  45. package/guides/validation.md +175 -0
  46. package/guides/view-transitions.md +120 -0
  47. package/package.json +4 -3
@@ -0,0 +1,340 @@
1
+ # Queries
2
+
3
+ > Reading from the server over GET, and letting TanStack Query or SWR own everything above it.
4
+
5
+ `query()` marks a server function as a read, so the call goes out as a GET
6
+ instead of a POST. Your logs and rate limiters can then tell it apart from a
7
+ mutation, and a cache can hold the answer.
8
+
9
+ ```ts title="src/listings.ts"
10
+ "use server"
11
+
12
+ import { query } from "@rsc-kit/core/query"
13
+
14
+ export const getListings = query(async (kind: string) => {
15
+ return db.listings.where({ kind })
16
+ })
17
+ ```
18
+
19
+ That is the whole feature. There is no cache here, no batching and no
20
+ deduplication: [TanStack Query](https://tanstack.com/query) and
21
+ [SWR](https://swr.vercel.app) already do those and do them better. **This owns
22
+ the transport; they own everything above it.**
23
+
24
+ You still need `"use server"`, and it is not claiming your function mutates.
25
+ Despite the name it means *this may be called from the browser* — it is what
26
+ gives the function an id and the client a stub to call it through.
27
+
28
+ Two markers, two jobs. `"use server"` says the function crosses the boundary.
29
+ `query()` says it is a read.
30
+
31
+ :::caution[The call site picks the method]
32
+ `query()` permits the GET; it does not force it. Calling the function directly
33
+ from a client component is still a POST, because the browser cannot tell a query
34
+ id from an action id — only `fetchQuery` sends a GET.
35
+
36
+ ```tsx
37
+ getListings(kind) // POST, even though it is a query
38
+ fetchQuery(getListings, [kind]) // GET
39
+ ```
40
+
41
+ Development warns from the server when a query arrives at the action endpoint,
42
+ so a forgotten `fetchQuery` shows up rather than quietly costing you the method.
43
+ :::
44
+
45
+ ## What it changes
46
+
47
+ Only the method. Same types, same arguments, same lack of an endpoint to write.
48
+
49
+ Three things follow from that:
50
+
51
+ - **Access logs and rate limiters** stop counting your reads as mutations. A
52
+ limiter that allows 10 writes a minute should not be spending them on a list
53
+ being refreshed.
54
+ - **A GET is the only shape a cache can keep.** A browser cache, a CDN or a
55
+ service worker can hold one; a POST can never be held by any of them.
56
+ - **Repeating it is safe by contract**, which is what a prefetcher, a crawler
57
+ or a retrying proxy assumes when it sees a GET.
58
+
59
+ TanStack Start makes the same choice from the other side: its `createServerFn()`
60
+ is a GET unless you ask for `{ method: 'POST' }`. We cannot default it that way
61
+ — React gives us one directive for everything, so a GET default would send
62
+ mutations as GET too.
63
+
64
+ ### If you skip it
65
+
66
+ A plain server action still works as a cache library's fetcher, and nothing
67
+ breaks:
68
+
69
+ ```tsx
70
+ useQuery({ queryKey: ["listings", kind], queryFn: () => getListings(kind) })
71
+ ```
72
+
73
+ You lose the three things above and keep everything else. So this is a
74
+ reasonable place to start, and `query()` is the thing to reach for once a read
75
+ is worth being precise about — anything hot enough to show up in a rate limiter,
76
+ or public enough to be worth caching.
77
+
78
+ One caveat on the second: data that is the same for everyone usually wants
79
+ [prerendering](/guides/static-generation/) rather than a client read at all, and
80
+ a personal read answers `no-store` and is not cached either way. The caching win
81
+ is real, and narrower than it sounds.
82
+
83
+ ## Reading it
84
+
85
+ **In a server component, call it.** It is ordinary server code — no HTTP, no
86
+ cache, no client involved:
87
+
88
+ ```tsx
89
+ const listings = await getListings("stay")
90
+ ```
91
+
92
+ **Better still, do not await it.** Pass the promise down and let a client
93
+ component resolve it, and the data streams with the page:
94
+
95
+ ```tsx
96
+ export default function Page() {
97
+ const listings = getListings("stay")
98
+
99
+ return (
100
+ <Suspense fallback={<Skeleton />}>
101
+ <List listings={listings} /> {/* "use client": use(listings) */}
102
+ </Suspense>
103
+ )
104
+ }
105
+ ```
106
+
107
+ React serialises the promise as a pending row in the payload, so the shell
108
+ paints at once and the rows arrive when the query answers — in the same
109
+ response, with no request from the browser. Nothing in this package is involved;
110
+ `use()` is React's. Reach for this first.
111
+
112
+ **When the browser decides what to read** — a filter, another page, a refresh —
113
+ hand `fetchQuery` to your cache library:
114
+
115
+ ```tsx
116
+ import { fetchQuery } from "@rsc-kit/core/queryClient"
117
+
118
+ // TanStack Query
119
+ useQuery({
120
+ queryKey: ["listings", kind],
121
+ queryFn: () => fetchQuery(getListings, [kind]),
122
+ })
123
+
124
+ // SWR
125
+ useSWR(["listings", kind], () => fetchQuery(getListings, [kind]))
126
+ ```
127
+
128
+ `fetchQuery` goes to the server every time. That is what a fetcher needs:
129
+ staleness, revalidation and deduplication belong to the library holding the
130
+ answer, not to the thing that fetches it.
131
+
132
+ :::caution[Keep the arrow]
133
+ TanStack calls a bare `queryFn` with its own context — `{ client, queryKey,
134
+ meta, signal }` — and a server function serialises whatever it is handed, so
135
+ passing one unwrapped would try to put an `AbortSignal` on the wire. The arrow
136
+ is where you choose what travels.
137
+ :::
138
+
139
+ ### Paging, polling, and the rest
140
+
141
+ All of it belongs to the library, and all of it works:
142
+
143
+ ```tsx
144
+ // Infinite. The cursor is just an argument.
145
+ useInfiniteQuery({
146
+ queryKey: ["feed"],
147
+ queryFn: ({ pageParam }) => fetchQuery(getFeed, [pageParam]),
148
+ initialPageParam: null,
149
+ getNextPageParam: (last) => last.nextCursor,
150
+ initialData: { pages: [first], pageParams: [null] },
151
+ staleTime: 60_000,
152
+ })
153
+
154
+ // Data that changes while you watch. No live connection needed.
155
+ useQuery({ queryKey: ["seats"], queryFn: () => fetchQuery(getSeats, []), refetchInterval: 2_000 })
156
+ ```
157
+
158
+ Page one comes from a server component and costs no request; later pages are
159
+ ordinary reads. `/infinite`, `/pagination` and `/polling` in the example app are
160
+ these three patterns end to end.
161
+
162
+ :::caution[A seed is stale by default]
163
+ TanStack treats `initialData` as stale at its default `staleTime` of `0`, so
164
+ without an explicit `staleTime` it refetches page one on mount and the round
165
+ trip you seeded to avoid happens anyway. SWR's `fallbackData` has the same
166
+ shape of caveat, and `refetchInterval` pauses while the tab is hidden.
167
+ :::
168
+
169
+ ### Where it will not work
170
+
171
+ `fetchQuery` only works in the browser. React refuses a server-function call
172
+ during the first render, so a component that also renders on the server must not
173
+ call it there.
174
+
175
+ You will not hit this through TanStack or SWR — they fetch in an effect, after
176
+ hydration.
177
+
178
+ ## Sharing a check across reads
179
+
180
+ A query has no middleware above it, so each one checks for itself. If that means
181
+ writing the same check in every file, put it on the client you already use for
182
+ actions:
183
+
184
+ ```ts title="src/server/client.ts"
185
+ 'use server'
186
+
187
+ import { createActionClient } from '@rsc-kit/core/action'
188
+
189
+ export const client = createActionClient({ onError: report })
190
+ .use(async ({ next }) => {
191
+ const user = await currentUser()
192
+
193
+ if (!user) throw new Error('Not signed in')
194
+
195
+ return next({ ctx: { user } })
196
+ })
197
+ ```
198
+
199
+ ```ts title="src/server/posts.ts"
200
+ 'use server'
201
+
202
+ import { client } from './client'
203
+
204
+ export const createPost = client.input(schema).handler(async ({ input, ctx }) => …) // POST
205
+ export const getPosts = client.input(filter).query(async ({ input, ctx }) => …) // GET
206
+ ```
207
+
208
+ One client, one set of middleware, one `onError`. `.handler()` makes an action;
209
+ `.query()` makes a query. Neither can be added without the check.
210
+
211
+ ### They fail differently, on purpose
212
+
213
+ An action **returns** its failures, because React strips a thrown message in
214
+ production. A query **throws** — every cache library reports failure by
215
+ rejection, and one that answered with an error-shaped object would look like a
216
+ successful read of something odd.
217
+
218
+ So a refused read rejects with a real `Error`. A validation failure keeps its
219
+ fields:
220
+
221
+ ```tsx
222
+ const { error } = useQuery({ queryKey: ['posts'], queryFn: () => fetchQuery(getPosts, [filter]) })
223
+
224
+ error.message // 'Validation failed'
225
+ error.errors // { title: ['too short'] }
226
+ ```
227
+
228
+ ## Securing a query
229
+
230
+ A query is a public GET endpoint. Treat it as one.
231
+
232
+ ### Route middleware does not run
233
+
234
+ `middleware.ts` guards a **route**, and a query has no route — it is reachable
235
+ whoever is asking and whatever page they came from. A query behind a guarded
236
+ page is not guarded.
237
+
238
+ What you do get is the request: the visitor's cookies and session are bound for
239
+ the call, so a query can check for itself.
240
+
241
+ One query is fine written by hand:
242
+
243
+ ```ts
244
+ export const getOrders = query(async () => {
245
+ const user = await currentUser()
246
+
247
+ if (!user) throw new Error("Not signed in")
248
+
249
+ return db.orders.forUser(user.id)
250
+ })
251
+ ```
252
+
253
+ Twenty is twenty chances to forget, and the one you forget is the one that
254
+ matters. Put the check on the client you already use for actions and build
255
+ every read from it:
256
+
257
+ ```ts title="src/server/orders.ts"
258
+ 'use server'
259
+
260
+ import { client } from './client'
261
+
262
+ export const getOrders = client.query(async ({ ctx }) => db.orders.forUser(ctx.user.id))
263
+ ```
264
+
265
+ `ctx.user` is typed and non-null because the only way into the handler was
266
+ through the middleware that put it there — so a query **cannot be added without
267
+ the check**. Same client, same middleware, same `onError` as your actions;
268
+ `.handler()` makes a mutation and `.query()` makes a read. See
269
+ [sharing a check across reads](#sharing-a-check-across-reads) below.
270
+
271
+ Authorise on **identity, not arguments**. `getOrder(id)` that trusts the id is
272
+ the whole of an IDOR: the caller chooses the id.
273
+
274
+ ### Only `query()` is reachable
275
+
276
+ The endpoint refuses anything that is not a query, which is what keeps every
277
+ action you have registered off a GET url. So export the wrapped value, not the
278
+ bare function beside it.
279
+
280
+ An unknown id and a real-but-unmarked one get the same 404. Telling them apart
281
+ would let someone probe for your action ids.
282
+
283
+ ### A query must never write
284
+
285
+ It is a GET. A prefetcher, a crawler or a retry will repeat it.
286
+
287
+ ### What the endpoint does for you
288
+
289
+ - **Requires `X-RSC-Query`.** A GET with no unusual header is a *simple*
290
+ request, so any page anywhere could trigger one with
291
+ `<img src="…/_rsc/query?…">` and it would carry the visitor's cookies — CORS
292
+ stops them reading the answer, not the read running. That header is not
293
+ CORS-safelisted, so a browser preflights it and nothing here answers a
294
+ preflight. It is the same protection a POST carrying `X-RSC-Action` had.
295
+ - Refuses a cross-origin request when an `Origin` is present.
296
+ - Refuses an oversized url with `414` before decoding anything.
297
+ - Sends `Vary: Cookie`, so a cacheable answer is never shared between visitors.
298
+
299
+ None of that authorises anything. The query does that.
300
+
301
+ ### Arguments are public
302
+
303
+ They travel in the url, so they reach access logs, browser history and referrer
304
+ headers. Never take a token or a password reset code as a query argument — that
305
+ is an action.
306
+
307
+ Arguments too large for a url, or containing a `File`, make the read fall back
308
+ to a POST rather than failing. It still works; it simply stops being cacheable,
309
+ and development warns when it happens.
310
+
311
+ ## Caching
312
+
313
+ Answers default to `private, no-store`. A query may read the session, and a
314
+ cacheable answer to a personal read is how one visitor is served another's data.
315
+
316
+ ```ts
317
+ export const getPricing = query(async () => tiers(), { cache: "public", maxAge: 300 })
318
+ ```
319
+
320
+ ### You probably do not need to change it
321
+
322
+ There are two caches and they solve different problems.
323
+
324
+ **Your cache library's**, in memory and per tab, decides whether to ask again.
325
+ It needs nothing from this option — `no-store` does not stop TanStack or SWR
326
+ holding an answer, because they are not an HTTP cache.
327
+
328
+ **The HTTP one**, which `cache` controls, decides whether an answer survives a
329
+ reload, works offline, or can be shared by a CDN.
330
+
331
+ Most apps want only the first. And note that data which is the same for everyone
332
+ usually wants [prerendering](/guides/static-generation/) rather than a client
333
+ read at all — which is a better answer than any cache. For a personal read you
334
+ want across reloads, persist your cache library rather than widening the read.
335
+
336
+ ## During a build
337
+
338
+ A query is an ordinary function, so it follows the ordinary rule. One reading a
339
+ database the build machine can reach runs at build time and the page is frozen
340
+ with real data in it. Call `connection()` inside the query to opt out.
@@ -0,0 +1,153 @@
1
+ # React Compiler
2
+
3
+ > Enabling the compiler in the build.
4
+
5
+ The React Compiler memoises client components for you, so `useMemo`,
6
+ `useCallback` and `React.memo` mostly stop being things you write.
7
+
8
+ Nothing here special-cases it. The build runs your project's own Vite config,
9
+ so the compiler is enabled the way it is in any Vite app — by adding
10
+ `@vitejs/plugin-react` after `rscKit()` and turning it on there.
11
+
12
+ <Aside type="note" title="Order matters">
13
+ `rscKit()` includes `@vitejs/plugin-rsc`, which has to see modules before
14
+ any React layer transforms them. The React plugin goes **after** it.
15
+ </Aside>
16
+
17
+ ## Two ways to run it
18
+
19
+ The compiler has a native implementation and a Babel one. Both produce the same
20
+ transform; they differ in what they cost to run and how settled they are.
21
+
22
+ ### Native, through oxc
23
+
24
+ The faster path, and the least to install. `compiler: true` is
25
+ [experimental](https://react.dev/learn/react-compiler/installation) and needs
26
+ `oxc-transform-react` present — the plugin looks for it by name:
27
+
28
+ <PackageManagers pkg="@vitejs/plugin-react oxc-transform-react" dev />
29
+
30
+ ```ts title="vite.config.ts"
31
+ import { defineConfig } from 'vite';
32
+ import react from '@vitejs/plugin-react';
33
+ import { rscKit } from '@rsc-kit/core/vite';
34
+
35
+ export default defineConfig({
36
+ plugins: [
37
+ rscKit({ sourceDir: 'src' }),
38
+ react({ compiler: true }),
39
+ ],
40
+ });
41
+ ```
42
+
43
+ Pass an object instead of `true` to configure it.
44
+
45
+ ### Babel
46
+
47
+ The reference implementation. In `@vitejs/plugin-react` 6 the inline `babel`
48
+ option was removed, so the preset is applied through `@rolldown/plugin-babel`:
49
+
50
+ <PackageManagers pkg="@vitejs/plugin-react @rolldown/plugin-babel babel-plugin-react-compiler" dev />
51
+
52
+ ```ts title="vite.config.ts"
53
+ import { defineConfig } from 'vite';
54
+ import react, { reactCompilerPreset } from '@vitejs/plugin-react';
55
+ import babel from '@rolldown/plugin-babel';
56
+ import { rscKit } from '@rsc-kit/core/vite';
57
+
58
+ export default defineConfig({
59
+ plugins: [
60
+ rscKit({ sourceDir: 'src' }),
61
+ react(),
62
+ babel({ presets: [reactCompilerPreset()] }),
63
+ ],
64
+ });
65
+ ```
66
+
67
+ On `@vitejs/plugin-react` 5 and earlier, the inline option still exists:
68
+
69
+ ```ts
70
+ react({
71
+ babel: { plugins: ['babel-plugin-react-compiler'] },
72
+ })
73
+ ```
74
+
75
+ ## Checks worth running first
76
+
77
+ The compiler only memoises components it can prove are safe to memoise, and it
78
+ skips the rest silently. These are how you find out which is which, and they
79
+ are worth running **before** you turn it on rather than after.
80
+
81
+ **Type checking.** The compiler assumes your code means what its types say. Run
82
+ `tsc --noEmit` and fix what it reports first — an untyped `any` threading
83
+ through a component is exactly the shape the compiler has to give up on.
84
+
85
+ ```bash
86
+ tsc --noEmit
87
+ ```
88
+
89
+ **The health check.** Reports how many components in your codebase the compiler
90
+ can handle, and why the others are refused:
91
+
92
+ ```bash
93
+ npx react-compiler-healthcheck
94
+ ```
95
+
96
+ **The lint rule.** `eslint-plugin-react-hooks` includes the compiler's own
97
+ diagnostics — the Rules of React violations that make a component
98
+ uncompilable — so they surface as you write rather than as silence in the
99
+ build:
100
+
101
+ ```js title="eslint.config.js"
102
+ import reactHooks from 'eslint-plugin-react-hooks';
103
+
104
+ export default [reactHooks.configs.recommended];
105
+ ```
106
+
107
+ **StrictMode.** The compiler's assumptions are the Rules of React, and
108
+ StrictMode is what surfaces breaking them at runtime — double-invoked renders
109
+ catch the impure ones.
110
+
111
+ <Aside type="tip" title="It applies to client components">
112
+ Server components render once and are thrown away, so there is nothing to
113
+ memoise in them. The compiler earns its keep in the `"use client"` half of
114
+ the app.
115
+ </Aside>
116
+
117
+ ## Confirming it ran
118
+
119
+ The compiler leaves a cache array at the top of every component it compiled.
120
+ Build without minification and look for it:
121
+
122
+ ```js
123
+ function Counter() {
124
+ const $ = _c(10); // ← compiled
125
+ const [count, setCount] = useState(0);
126
+
127
+ if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
128
+ // …
129
+ }
130
+ }
131
+ ```
132
+
133
+ No `_c(...)` and no `memo_cache_sentinel` means that component was skipped —
134
+ which the health check will explain.
135
+
136
+ ## Opting a component out
137
+
138
+ ```tsx
139
+ "use client";
140
+
141
+ export default function LegacyWidget() {
142
+ "use no memo";
143
+
144
+ return <div>…</div>;
145
+ }
146
+ ```
147
+
148
+ ## Turning it off
149
+
150
+ Drop `compiler: true`, or remove the plugin. Neither the router nor the build
151
+ depends on it being there.
152
+
153
+ Further reading: [React Compiler installation](https://react.dev/learn/react-compiler/installation).
@@ -0,0 +1,143 @@
1
+ # Redirects
2
+
3
+ > Leaving a page from inside the render, and what that costs.
4
+
5
+ ```tsx title="src/app/products/[slug]/page.tsx"
6
+ import { redirect } from '@rsc-kit/core/redirect';
7
+ import { findProduct } from '../../../data';
8
+
9
+ export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) {
10
+ const { slug } = await params;
11
+ const product = await findProduct(slug);
12
+
13
+ if (!product) redirect('/products');
14
+
15
+ return <h1>{product.name}</h1>;
16
+ }
17
+ ```
18
+
19
+ `redirect` never returns — it throws, which is what stops the component. The
20
+ default status is `307`, because it preserves the method: a redirect out of a
21
+ `POST` does not silently become a `GET` of the target.
22
+
23
+ A redirect during a navigation stays a navigation. The document does not reload,
24
+ the layouts you are inside stay mounted, and only the part below them changes.
25
+
26
+ The url that redirected replaces its history entry rather than adding one, so
27
+ Back does not land on it and redirect you again.
28
+
29
+ ## Where you call it matters
30
+
31
+ This is the part worth understanding, because it is also the security-relevant
32
+ part. Headers flush early on purpose — that is what makes the first paint fast
33
+ — so a redirect decided late has no status line left to use. There are two
34
+ windows, and nothing you write chooses between them:
35
+
36
+ | Called | Answered with | What the browser saw first |
37
+ | --- | --- | --- |
38
+ | Above every `<Suspense>` boundary | A real `3xx`, or `X-RSC-Redirect` on a navigation | Nothing at all |
39
+ | Inside a boundary | The shell, then the redirect | Layouts, and the fallbacks standing in for what never arrived |
40
+
41
+ Neither buffers the response. Before anything is written the host is still
42
+ waiting on the shell, so a component that redirects instead of rendering is
43
+ caught there. After that, React already carries an error digest to the client
44
+ and the destination rides along in it.
45
+
46
+ <Aside type="caution" title="Middleware belongs above the boundaries">
47
+ A `loading.tsx` wraps the whole page in `<Suspense>`. That is usually what you
48
+ want — it is how a page gets a frozen shell — but it also means an `await` at
49
+ the top of that page is *inside* a boundary, and a redirect after it arrives
50
+ in the second window.
51
+
52
+ Nothing from inside the boundary was shown, so a not-found redirect is fine
53
+ there. An authorization check is different: the layouts above it already
54
+ rendered and already went out. If a page must reveal nothing at all, the
55
+ check has to run somewhere that blocks the shell.
56
+ </Aside>
57
+
58
+ ## Where to put an authorization check
59
+
60
+ In order of preference:
61
+
62
+ **In `middleware.ts`.** A file beside the layout, run before anything at or below it
63
+ renders, on every path. This is the one built for the job.
64
+
65
+
66
+ <Aside type="danger" title="A layout is not a security boundary">
67
+ It is tempting — a layout renders above the page's boundary, so on a full
68
+ page load a redirect there happens before anything is sent. But a navigation
69
+ tells the server which layouts the client already has, in `X-RSC-Segments`,
70
+ and the server skips re-rendering those. A client that *claims* to hold your
71
+ layout skips the check:
72
+
73
+ ```bash
74
+ curl -H 'X-RSC: true' -H 'X-RSC-Segments: app/layout' /guarded
75
+ # 204, X-RSC-Redirect: /orders ← the middleware ran
76
+
77
+ curl -H 'X-RSC: true' -H 'X-RSC-Segments: app/layout,app/guarded/layout' /guarded
78
+ # 200, and the page's content ← it did not
79
+ ```
80
+
81
+ The header is not verified and cannot be. Put the check in a `middleware.ts`
82
+ beside the layout instead — it runs before anything below it renders, on
83
+ every path. See [Authorization](/guides/authorization).
84
+ </Aside>
85
+
86
+ **At the top of a page with no `loading.tsx`.** Runs on every render of that
87
+ page, so it is not skippable the way a layout is — but fragile in a different
88
+ way: adding a `loading.tsx` later silently moves the check into the second
89
+ window, and nothing warns.
90
+
91
+ <Aside type="danger" title="Never catch it and continue">
92
+ `redirect` communicates by throwing. A `try`/`catch` that swallows everything
93
+ turns the redirect into a blank region — the component stops, and nothing
94
+ takes its place. If you must wrap the call, rethrow what you do not
95
+ recognise:
96
+
97
+ ```ts
98
+ import { isRedirectSignal } from '@rsc-kit/core/redirect';
99
+
100
+ try {
101
+ await mightRedirect();
102
+ } catch (error) {
103
+ if (isRedirectSignal(error)) throw error;
104
+ // …
105
+ }
106
+ ```
107
+ </Aside>
108
+
109
+ ## From a server action
110
+
111
+ An action is not a render, so there is no shell to be on either side of. Throw
112
+ from the action and the client follows it:
113
+
114
+ ```ts title="src/actions.ts"
115
+ 'use server'
116
+
117
+ import { redirect } from '@rsc-kit/core/redirect';
118
+
119
+ export async function createPost(title: string) {
120
+ const post = await savePost(title);
121
+
122
+ redirect(`/posts/${post.slug}`);
123
+ }
124
+ ```
125
+
126
+ ## A route that only redirects
127
+
128
+ The redirect itself is stored, so it costs no render at all:
129
+
130
+ ```text
131
+ ○ /old-pricing (redirects to /pricing)
132
+ ```
133
+
134
+ The build writes the status and the location, and the host answers from that
135
+ file — a document gets the status code, a navigation gets `X-RSC-Redirect` and
136
+ does it as an SPA navigation. Nothing is rendered per request, and a static
137
+ export can carry it.
138
+
139
+ ## Loops
140
+
141
+ A navigation follows at most **8** redirects before throwing. A page that
142
+ redirects to itself is a mistake someone will make, and without a ceiling it is
143
+ an unbounded run of full renders rather than an error you can see.
@@ -0,0 +1,66 @@
1
+ # Headers and cookies
2
+
3
+ > Setting response headers and cookies during a render.
4
+
5
+ ## Reading
6
+
7
+ Anywhere in a server component or action:
8
+
9
+ ```tsx
10
+ import { headers, cookies, searchParams } from '@rsc-kit/core/request'
11
+
12
+ export default async function Page() {
13
+ const h = await headers()
14
+ const jar = await cookies()
15
+
16
+ return <p>Hello {jar.get('name')?.value ?? 'stranger'}</p>
17
+ }
18
+ ```
19
+
20
+ ## Writing
21
+
22
+ ```tsx
23
+ import { responseHeaders, cookies } from '@rsc-kit/core/request'
24
+
25
+ export async function middleware() {
26
+ responseHeaders().set('X-Frame-Options', 'DENY')
27
+
28
+ const jar = await cookies()
29
+ jar.set('last-seen', new Date().toISOString(), { httpOnly: true, path: '/' })
30
+ }
31
+ ```
32
+
33
+ **Writing only works in middleware.** Middleware runs before the render, while
34
+ the response line has not been sent yet. A component runs *during* streaming,
35
+ when the headers are already on the wire — writing from one throws rather than
36
+ being silently dropped, so you find out immediately.
37
+
38
+ A redirect carries them too, which is what lets middleware remember where
39
+ someone was going before sending them to log in:
40
+
41
+ ```tsx
42
+ export async function middleware() {
43
+ const jar = await cookies()
44
+
45
+ if (!jar.get('session')) {
46
+ jar.set('intended', '/dashboard', { path: '/' })
47
+ redirect('/login')
48
+ }
49
+ }
50
+ ```
51
+
52
+ ## Cookie options
53
+
54
+ ```ts
55
+ jar.set('name', 'value', {
56
+ httpOnly: true,
57
+ secure: true,
58
+ sameSite: 'lax', // 'strict' | 'lax' | 'none'
59
+ path: '/',
60
+ maxAge: 60 * 60 * 24,
61
+ expires: new Date('2027-01-01'),
62
+ })
63
+ ```
64
+
65
+ Names are validated as cookie tokens and `sameSite` / `expires` are checked, so
66
+ a typo is an error rather than a header the browser quietly ignores.