@rsc-kit/mcp 0.20.6 → 0.20.8
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.
- package/dist/recipes.js +89 -16
- package/dist/recipes.js.map +1 -1
- package/guides/deployment.md +27 -0
- package/guides/domains.md +6 -4
- package/guides/index.json +1 -1
- package/guides/metadata.md +10 -1
- package/guides/navigation.md +91 -6
- package/guides/ppr.md +11 -1
- package/guides/routing.md +1 -1
- package/guides/server-actions.md +23 -0
- package/guides/view-transitions.md +85 -52
- package/package.json +1 -1
package/dist/recipes.js
CHANGED
|
@@ -174,24 +174,48 @@ react-hook-form, a different system for the same job. One or the other.`,
|
|
|
174
174
|
{
|
|
175
175
|
topic: 'prefetch',
|
|
176
176
|
summary: 'Making a navigation feel instant',
|
|
177
|
-
body: `\`<Link>\`
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
177
|
+
body: `\`<Link>\` fetches its payload as it comes into view, on EVERY device
|
|
178
|
+
(desktop too), when the browser is idle, once per link, as Next does - the
|
|
179
|
+
first 12 links in view per page (a payload is the whole page, 30 KB for a
|
|
180
|
+
product with its related products; the rest wait for a touch or a settled
|
|
181
|
+
hover) - the bytes only, held 30 s (per-visitor page) or 5 min (a page the build made,
|
|
182
|
+
marked public), and the eager <img>s the payload names are preloaded at low
|
|
183
|
+
priority as it lands (the first 6 a page; not loading="lazy"; nothing under
|
|
184
|
+
Save-Data), so a product picture is decoded before the tap. Do NOT write an
|
|
185
|
+
image-prefetch route or an effect per card in the app. It is decoded - which
|
|
186
|
+
loads the page's client chunks - on intent: a pointer settled 100 ms on the link, a mousedown, a touchstart,
|
|
187
|
+
each 60-300 ms before the click. A click then finds a decoded tree and
|
|
188
|
+
nothing on the network is between the click and the page: the SPA feel,
|
|
189
|
+
on a slow network too. A link to a guarded page (Sign in -> /agent ->
|
|
190
|
+
/login) prefetches the redirect's destination too, so the tap asks for
|
|
191
|
+
nothing. Save-Data turns the viewport prefetch off. To MEASURE a navigation
|
|
192
|
+
(the number "slow on my phone" needs): every navigation leaves User Timing
|
|
193
|
+
marks rsc-kit:navigate:start / :decoded / :applied and a measure
|
|
194
|
+
rsc-kit:navigate (click to the commit) - in the Performance panel, or
|
|
195
|
+
performance.getEntriesByName('rsc-kit:navigate') in a console; a gap
|
|
196
|
+
before :decoded is network or chunks (the link was not on screen, or
|
|
197
|
+
prefetch={false}), after :applied is the render. A tap BEFORE the runtime has
|
|
182
198
|
hydrated is held by the bootstrap script and navigated to once the router
|
|
183
199
|
is wired (a document load after 4 s if the runtime never comes) - so a slow
|
|
184
|
-
phone's first tap is not a full reload.
|
|
200
|
+
phone's first tap is not a full reload. A form posting a server action
|
|
201
|
+
submitted in that window is held and submitted again after hydration, so
|
|
202
|
+
"Add to cart" tapped early is an action, not a full-page POST. Usually there is nothing to do; do
|
|
185
203
|
NOT add a viewport observer, touch handler or pre-hydration click shim in
|
|
186
204
|
the app. A tag manager on the main thread (Facebook Pixel, Clarity) is the
|
|
187
205
|
usual reason hydration is late: load those after the page is interactive.
|
|
188
206
|
A <ViewTransition> around the page is the usual reason a navigation is
|
|
189
207
|
slow on an iPhone (every iOS browser is WebKit): WebKit snapshots the
|
|
190
208
|
outgoing page at its FULL height, a 12,000 px landing page is 44 megapixels
|
|
191
|
-
at 3x, ~600 ms frozen per navigation and enough memory for iOS to
|
|
192
|
-
the tab (which returns as a reload).
|
|
193
|
-
|
|
194
|
-
|
|
209
|
+
at 3x, ~600-900 ms frozen per navigation and enough memory for iOS to
|
|
210
|
+
discard the tab (which returns as a reload). A navigation here is an
|
|
211
|
+
instant swap, like Next - do NOT add a page fade in a port; a port that
|
|
212
|
+
has one should remove it. The transition worth having is a shared element
|
|
213
|
+
(<ViewTransition name=...> on one image). If a page fade is wanted anyway,
|
|
214
|
+
<PageTransition className=...> from @rsc-kit/core/PageTransition is the
|
|
215
|
+
one that skips WebKit (and reduced motion), and ships its own CSS - a
|
|
216
|
+
120 ms cross-fade with pointer-events off on the overlay; nothing to add to
|
|
217
|
+
the stylesheet. Props: duration (ms, 0 = instant swap), webkit="run" for a
|
|
218
|
+
one-screen page that measured fine. See read_guide view-transitions.
|
|
195
219
|
|
|
196
220
|
\`\`\`tsx
|
|
197
221
|
import Link from '@rsc-kit/core/Link'
|
|
@@ -208,13 +232,21 @@ is genuinely computed.
|
|
|
208
232
|
To prefetch from code — a row about to be clicked, a wizard's next step:
|
|
209
233
|
|
|
210
234
|
\`\`\`ts
|
|
211
|
-
import { prefetch } from '@rsc-kit/core/
|
|
235
|
+
import { prefetch } from '@rsc-kit/core/router'
|
|
212
236
|
|
|
213
237
|
prefetch('/orders/42')
|
|
238
|
+
prefetch('/orders/42', undefined, { intent: true }) // and decode it now: the chunks too
|
|
214
239
|
\`\`\`
|
|
215
240
|
|
|
216
241
|
What is prefetched is the RSC payload, not the html, so it is small and it warms
|
|
217
|
-
the same cache the navigation will read
|
|
242
|
+
the same cache the navigation will read. Never the page on screen, and never a
|
|
243
|
+
page still held behind it (the page just left): a navigation to that reveals
|
|
244
|
+
it. After an action that revalidated, and after refresh(), every prefetched
|
|
245
|
+
payload and every held page is dropped - they are from before the write - so
|
|
246
|
+
visit() to a list after creating a row fetches the list with the row in it.
|
|
247
|
+
What an action re-rendered goes under the page it was invoked on: a tap that
|
|
248
|
+
left the page while the action was in flight is answered by fetching the page
|
|
249
|
+
now showing again, whole, never by putting the old page under the new url.`,
|
|
218
250
|
},
|
|
219
251
|
{
|
|
220
252
|
topic: 'validation',
|
|
@@ -682,7 +714,12 @@ decision, not a build one. Its response is marked private so no cache keeps it.`
|
|
|
682
714
|
topic: 'dynamic',
|
|
683
715
|
summary: 'Why a page is not static, and how to choose',
|
|
684
716
|
body: `A page is stored at build time unless it reads the request. Reading it is what
|
|
685
|
-
opts out, and the accessors are async
|
|
717
|
+
opts out, and the accessors are async. usePathname() in a client component on
|
|
718
|
+
a route that lists no urls ([id] with no generateStaticParams) answers "" in
|
|
719
|
+
that route's shell - one shell serves every url, so no link is active and a
|
|
720
|
+
breadcrumb is empty until hydration, when the hook moves to the browser's url
|
|
721
|
+
by itself; the boot payload agrees with the shell, so nothing mismatches.
|
|
722
|
+
Nothing to wrap, nothing to do.
|
|
686
723
|
|
|
687
724
|
\`\`\`ts
|
|
688
725
|
import { cookies, headers, searchParams, connection } from '@rsc-kit/core/request'
|
|
@@ -771,7 +808,13 @@ phone; a layout that renders either itself is left alone. To change it:
|
|
|
771
808
|
export const viewport: Viewport (from @rsc-kit/core/metadata) on a layout or
|
|
772
809
|
page, Next's shape - width, initialScale, maximumScale, userScalable,
|
|
773
810
|
viewportFit, themeColor (string or [{ media, color }]), colorScheme - merged
|
|
774
|
-
outer to inner then the page
|
|
811
|
+
outer to inner then the page.
|
|
812
|
+
|
|
813
|
+
generateMetadata that reads params on a route that lists no urls: the PPR
|
|
814
|
+
shell is one file for the whole pattern, so the build leaves that metadata
|
|
815
|
+
out of it (no placeholder title baked in) and the host writes the real
|
|
816
|
+
title and description into the head when it serves the shell for a url;
|
|
817
|
+
the client sets the title again after hydration. Nothing to do in the app.`,
|
|
775
818
|
},
|
|
776
819
|
{
|
|
777
820
|
topic: 'fonts',
|
|
@@ -1005,7 +1048,7 @@ WHAT EACH PIECE BECOMES
|
|
|
1005
1048
|
Inertia::render('Error') -> error.tsx, not-found.tsx
|
|
1006
1049
|
RedirectResponse from a controller -> redirect() from @rsc-kit/core/redirect in the page or guard;
|
|
1007
1050
|
RscRedirectException from PHP is performed by the browser
|
|
1008
|
-
NProgress ->
|
|
1051
|
+
NProgress -> <PageTransition> from @rsc-kit/core/PageTransition, or nothing
|
|
1009
1052
|
|
|
1010
1053
|
Auth pages: Auth.login with a LoginRequest calling Auth::attempt() - a cookie
|
|
1011
1054
|
queued during the call lands on the response; redirect('/dashboard') in the
|
|
@@ -1188,7 +1231,9 @@ and ips are always own.
|
|
|
1188
1231
|
acme.example.com/settings -> /acme/settings app/[domain]/settings/page.tsx, domain "acme"
|
|
1189
1232
|
acme.com/settings -> /acme.com/settings the same file, domain "acme.com" (other host = whole host)
|
|
1190
1233
|
|
|
1191
|
-
The visitor's url is untouched; only the match changes.
|
|
1234
|
+
The visitor's url is untouched; only the match changes. Only a top-level
|
|
1235
|
+
[domain] or [host] directory binds the host; app/[slug] or app/[collection] at
|
|
1236
|
+
the top is a path parameter, as in Next. A top-level [domain]
|
|
1192
1237
|
binds ONLY from a host, never from a path: example.com/nope is a 404, not a
|
|
1193
1238
|
tenant called "nope". Otherwise [domain] is an ordinary dynamic segment: params.domain in every page/layout under it, typed
|
|
1194
1239
|
route('/[domain]/settings', { domain }), loading/error files as usual. A
|
|
@@ -1366,6 +1411,34 @@ applies the .env value after plugins run), so remove the line - other tools
|
|
|
1366
1411
|
that want it keep it in their own .env.
|
|
1367
1412
|
|
|
1368
1413
|
Full guide: read_guide({ slug: 'instrumentation' }).`,
|
|
1414
|
+
},
|
|
1415
|
+
{
|
|
1416
|
+
topic: 'workers',
|
|
1417
|
+
summary: 'Cloudflare Workers: bindings (D1, R2, KV) from cloudflare:workers',
|
|
1418
|
+
body: `A D1 database, an R2 bucket or a KV namespace named in wrangler.jsonc is
|
|
1419
|
+
read from \`cloudflare:workers\`, the runtime's own module: the bundle leaves
|
|
1420
|
+
it external, like bun:sqlite. \`vite dev\` under Bun or Node has no such module,
|
|
1421
|
+
so import it lazily, behind a runtime check - never at the top of a module a
|
|
1422
|
+
page imports, or dev and the build fail to resolve it.
|
|
1423
|
+
|
|
1424
|
+
\`\`\`ts
|
|
1425
|
+
const onWorkers = typeof navigator !== 'undefined' && navigator.userAgent === 'Cloudflare-Workers'
|
|
1426
|
+
|
|
1427
|
+
export async function db() {
|
|
1428
|
+
if (onWorkers) {
|
|
1429
|
+
const { env } = await import('cloudflare:workers')
|
|
1430
|
+
return env.DB
|
|
1431
|
+
}
|
|
1432
|
+
const { Database } = await import('bun:sqlite')
|
|
1433
|
+
return new Database('data/local.sqlite')
|
|
1434
|
+
}
|
|
1435
|
+
\`\`\`
|
|
1436
|
+
|
|
1437
|
+
The build renders through the same module: a page that reads a table and
|
|
1438
|
+
nothing from the request is frozen from the local database at build time,
|
|
1439
|
+
and only pages that read the url or the request reach D1 per request. Do NOT
|
|
1440
|
+
put env.DB on a global or read it at import time. wrangler.jsonc: the
|
|
1441
|
+
d1_databases / r2_buckets entries wrangler printed at create time.`,
|
|
1369
1442
|
},
|
|
1370
1443
|
{
|
|
1371
1444
|
topic: 'bun',
|
package/dist/recipes.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"recipes.js","sourceRoot":"","sources":["../src/recipes.ts"],"names":[],"mappings":"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","sourcesContent":["// How to build the things this framework has, in the shape that works.\n//\n// The other half of this server, and the more useful one. Introspection answers\n// \"what did my build do\"; this answers \"how do I do X here\", which is the\n// question an agent actually has — and the one it otherwise answers from Next\n// and React habits that produce code which looks right and is not.\n//\n// Long-form on purpose. AGENTS.md has to be short enough to sit in context for\n// every turn, so it can only say the rule. These are fetched when the topic\n// comes up, so they can afford the working example and the caveat under it.\n//\n// Every snippet here is the recommended spelling from the guides, not a\n// paraphrase. When a guide changes, this changes with it — a recipe that has\n// drifted is worse than no recipe, because it is followed with confidence.\n\nexport interface Recipe {\n topic: string\n summary: string\n body: string\n}\n\nconst RECIPES: Recipe[] = [\n {\n topic: 'forms',\n summary: 'Submitting to a server action, with pending state and field errors. Uncontrolled by default - no useState per field',\n body: `THE RULE: forms are UNCONTROLLED. Inputs keep their value in the DOM,\nan initial value is defaultValue, the action reads FormData. Do NOT write\nuseState + value/onChange per input, and do NOT reach for TanStack Form.\nControl ONE field only when the UI must react as the user types (a character\ncount, a live preview, a dependent select) - bind it with useField, which\nscopes the re-render to that field. Everything else stays uncontrolled.\n\nUse <Form>. It takes the server action itself, not a url.\n\n\\`\\`\\`tsx\n'use client'\nimport Form from '@rsc-kit/core/Form'\nimport { createPost } from '../actions'\n\nexport function NewPost() {\n return (\n <Form action={createPost} schema={schema}>\n {({ pending, errors }) => (\n <>\n <input name=\"title\" />\n {errors.title?.[0] && <p>{errors.title[0]}</p>}\n <button disabled={pending}>Save</button>\n </>\n )}\n </Form>\n )\n}\n\\`\\`\\`\n\nPassing \\`schema\\` validates in the browser BEFORE the action is called, so a\nmistake costs no round trip. It is a courtesy, never a control: the action is a\npublic endpoint reachable without your form, so the server must check too.\n\nA schema on the server (\\`client.input(schema)\\`) does NOT give you client-side\nvalidation. Pass it to the form as well — the same schema is fine.\n\nValues are uncontrolled, so an initial one is React's own \\`defaultValue\\`. A\nrefused submit keeps what was typed, because the DOM kept it.\n\nA repeated name is an array. With one selected it is a string, which no\nz.array() accepts - so for anything that is a list by nature end the name in\n\\`[]\\` and it is always an array, brackets dropped from the key:\n\n\\`\\`\\`tsx\n<input type=\"checkbox\" name=\"tags[]\" value=\"react\" /> // -> { tags: ['react'] }\n\\`\\`\\`\n\nNames that describe a shape build it: \\`address.city\\` nests, and\n\\`items[0].name\\` (or \\`items[0][name]\\`) makes an array of objects. That is\nthe shape the schema was written against, and errors come back keyed the same\nway because Standard Schema issue paths join with dots too.\n\nFor a control with no native element behind it - a rich editor, a Radix select -\nor a value read as it is typed, bind it with \\`field()\\`. It is the same four\nprops react-hook-form's Controller gives:\n\n\\`\\`\\`tsx\n<Form action={save} defaultValues={{ body: '' }}>\n {({ field }) => (\n <>\n <Editor {...field('body')} />\n <span>{field('body').value.length}/100</span>\n </>\n )}\n</Form>\n\\`\\`\\`\n\nonChange takes a DOM event OR a bare value, so native inputs and Radix\ncomponents both work. A bound field is still an ordinary named input, so it\narrives in FormData with the rest - nothing merges.\n\n\\`fieldState(name)\\` is the other half: { touched, invalid, errors }. Two\nobjects rather than one because touched and invalid are not DOM attributes and\nspreading them would warn on every field.\n\n\\`\\`\\`tsx\nconst title = fieldState('title')\n<Field data-invalid={title.invalid}>\n <Input {...field('title')} aria-invalid={title.invalid} />\n <FieldError errors={title.errors.map((message) => ({ message }))} />\n</Field>\n\\`\\`\\`\n\nA field is checked when it is LEFT, not as it is typed, and it works on\nuncontrolled fields too - the form listens for focusout rather than each field\nlistening for blur.\n\nThere is no per-field render prop component here, and that is deliberate.\nTanStack Form is controlled-first, so it needs one - without per-field\nsubscriptions a keystroke re-renders every field. react-hook-form is\nuncontrolled-first like this, and its Controller scopes the re-render of a\ncontrolled field to itself.\n\nfield() is a function call instead, which keeps the markup flat and means a\nbound field re-renders the form rather than only itself. Right for the one or\ntwo controlled fields a form usually has.\n\nWhen it is not, put the field in its own component and use \\`useField\\` there -\nit re-renders that component and nothing else, which is what Controller achieves\nwith a render prop:\n\n\\`\\`\\`tsx\nfunction Title() {\n const { invalid, errors, ...bound } = useField('title')\n\n return <Input {...bound} aria-invalid={invalid} />\n}\n\\`\\`\\`\n\n\\`useFormValues()\\` reads every bound value from anywhere inside the form - a\npreview, a summary. Only BOUND values: an uncontrolled input's value is the\nDOM's and nothing can know it changed.\n\nBoth read a context, so they work below <Form>. For something that is NOT a\ndescendant - a top bar, a sidebar preview - create the store above both and\nhand it in:\n\n\\`\\`\\`tsx\nconst store = useFormStore({ title: '' })\n\n<TopBar store={store} /> // outside the form\n<Form action={save} store={store}>…</Form>\n\\`\\`\\`\n\nuseFormStore is the values and nothing else - no submit, no errors. Creating it\ndoes not subscribe to it, so the holder does not re-render per keystroke and\ntake the subtree with it. useField(name, store) and useFormValues(store) take\none explicitly; without one they read the context.\n\nA submit from outside the form is html, not a second api:\n\n\\`\\`\\`tsx\n<Form id=\"bug-report\" action={reportBug}>…</Form>\n<Button type=\"submit\" form=\"bug-report\">Submit</Button>\n\\`\\`\\`\n\nThere is no useForm hook. <Form> is the whole surface.\n\nFields are real \\`name\\` attributes rather than controlled state, so the form\nreads a native FormData and any component rendering a real control works.\n\nIt works before hydration. The action is on the form element as well as in the\nsubmit handler, so the markup is submittable on its own - the handler calls\npreventDefault() first and React does not run a form action for a cancelled\nsubmit, so exactly one path runs.\n\n**shadcn/ui works as-is.** Input, Textarea, Button and Label are styled native\nelements, so \\`name\\` does what it always does. Select, Checkbox, Switch and\nRadioGroup are Radix underneath and render a hidden native control whenever\ngiven a \\`name\\` - omit it and they are invisible to the form, which is the\nonly thing to remember.\n\nDo NOT use shadcn's own Form/FormField/FormControl with this. Those wrap\nreact-hook-form, a different system for the same job. One or the other.`,\n },\n {\n topic: 'prefetch',\n summary: 'Making a navigation feel instant',\n body: `\\`<Link>\\` prefetches on hover by default - and on a device with no hover (a\nphone) as the link comes into view, when the browser is idle, once per link,\nas Next does; a tap then lands on a payload already there. A link to a\nguarded page (Sign in -> /agent -> /login) prefetches the redirect's\ndestination too, so the tap asks for nothing. A tap BEFORE the runtime has\nhydrated is held by the bootstrap script and navigated to once the router\nis wired (a document load after 4 s if the runtime never comes) - so a slow\nphone's first tap is not a full reload. Usually there is nothing to do; do\nNOT add a viewport observer, touch handler or pre-hydration click shim in\nthe app. A tag manager on the main thread (Facebook Pixel, Clarity) is the\nusual reason hydration is late: load those after the page is interactive.\nA <ViewTransition> around the page is the usual reason a navigation is\nslow on an iPhone (every iOS browser is WebKit): WebKit snapshots the\noutgoing page at its FULL height, a 12,000 px landing page is 44 megapixels\nat 3x, ~600 ms frozen per navigation and enough memory for iOS to discard\nthe tab (which returns as a reload). Scope the transition to a\nviewport-height scrolling container, or turn it off when\nnavigator.vendor === 'Apple Computer, Inc.'. See read_guide view-transitions.\n\n\\`\\`\\`tsx\nimport Link from '@rsc-kit/core/Link'\n\n<Link href=\"/orders\">Orders</Link>\n<Link href=\"/orders\" prefetch={false}>Orders</Link> // opt out\n<Link href=\"/orders\" cacheFor={30_000}>Orders</Link> // hold the payload longer\n\\`\\`\\`\n\n\\`href\\` is typed to the routes the build found, so a link to a page that no\nlonger exists stops compiling. Cast with \\`as Route\\` only when the destination\nis genuinely computed.\n\nTo prefetch from code — a row about to be clicked, a wizard's next step:\n\n\\`\\`\\`ts\nimport { prefetch } from '@rsc-kit/core/navigate'\n\nprefetch('/orders/42')\n\\`\\`\\`\n\nWhat is prefetched is the RSC payload, not the html, so it is small and it warms\nthe same cache the navigation will read.`,\n },\n {\n topic: 'validation',\n summary: 'Checking input — forms, actions, urls and request bodies',\n body: `One contract everywhere: any Standard Schema (Zod, Valibot, ArkType).\n\n**Actions** validate on arrival and RETURN their failures, because React strips\na thrown message in production:\n\n\\`\\`\\`ts\nexport const createPost = client.input(schema).handler(async ({ input, ctx }) => …)\n\\`\\`\\`\n\n**Urls** validate by exporting a schema beside the page or route:\n\n\\`\\`\\`ts\nexport const params = z.object({ slug: z.string().min(1) })\nexport const searchParams = z.object({ page: z.coerce.number().int().min(1).default(1) })\n\\`\\`\\`\n\nValues arrive parsed and typed — \\`?page=3\\` is the number 3, a missing one is\nthe default. Never hand-parse \\`Number(searchParams.get('page'))\\`.\n\nThe same schema types every LINK to that page. Write search params as an\nobject, never as a string:\n\n\\`\\`\\`tsx\n<Link href=\"/search\" search={{ q: 'shoes', page: 2 }}>…</Link> // typed by the page's schema\nvisit(href('/search', { q: 'shoes' })) // same check, as a string\n\\`\\`\\`\n\nA key the page never reads, or a number written as text, does not compile;\na key the page requires is required on the link. A page with no schema takes\nany scalars. Do NOT build \\`?q=\\${q}\\` by hand when the page has a schema.\n\n**Api route bodies** the same way:\n\n\\`\\`\\`ts\nexport const body = z.object({ title: z.string().min(1) })\n\nexport async function POST(request: Request, { body }) {\n const { title } = await body\n}\n\\`\\`\\`\n\nThe failures answer differently on purpose:\n\n bad params 404 — the url does not describe a page\n bad searchParams the error boundary (400 for an api route)\n bad body 422, the status an action already uses\n\nA bad query is deliberately NOT a 404, or one bad link makes a real page look\ndeleted.`,\n },\n {\n topic: 'action-client',\n summary: 'Middleware for server actions, so a check cannot be forgotten',\n body: `\\`\\`\\`ts title=\"src/server/client.ts\"\n'use server'\nimport { createActionClient } from '@rsc-kit/core/action'\n\nexport const client = createActionClient({ onError: report })\n .use(async ({ next }) => {\n const user = await currentUser()\n\n if (!user) throw new ServerAuthenticationError()\n\n return next({ ctx: { user } })\n })\n\\`\\`\\`\n\n\\`\\`\\`ts title=\"src/server/posts.ts\"\n'use server'\nimport { client } from './client'\n\nexport const createPost = client.input(schema).handler(async ({ input, ctx }) => …)\nexport const getPosts = client.query(async ({ ctx }) => …)\n\\`\\`\\`\n\n\\`.handler()\\` is a mutation (POST). \\`.query()\\` is a read (GET). Both run the\nchain, so \\`ctx.user\\` is typed and non-null inside them.\n\nFor a failure the schema cannot know - an account not found, a slug taken -\nthe handler is given \\`fieldErrors\\`, typed to its own input so a field the\nschema does not have is a compile error:\n\n\\`\\`\\`ts\n.handler(async ({ input, fieldErrors }) => {\n if (!account) return fieldErrors({ email: 'Account not found' })\n})\n\\`\\`\\`\n\nWRITE return fieldErrors(...). It throws either way, but TypeScript cannot see\na never-return through a destructured argument, so without the return the\nvalue you checked stays possibly-undefined on the next line. It lands in\nvalidationErrors on that field, the same place a schema refusal does. This is\nnext-safe-action's returnValidationErrors with no schema argument and no\n_errors nesting.\n\nA plain \"use server\" function with no action client imports the same thing,\nuntyped, from '@rsc-kit/core/action' - the engine converts the throw into the\nreturned { validationErrors } on the way out. Same rule: return fieldErrors(...).\n\nThe point is not convenience. An action cannot be added without the check,\nbecause there is no other constructor to reach for.\n\nStack clients for a narrower rule:\n\n\\`\\`\\`ts\nexport const admin = client.use(async ({ ctx, next }) => {\n if (!ctx.user.isAdmin) throw new ServerAuthorizationError()\n return next({ ctx })\n})\n\\`\\`\\`\n\nRoute \\`middleware.ts\\` does NOT run for actions — an action renders no route.\nThat is why the check goes here.\n\nAn action body - arguments and any uploaded files, read whole - is capped at\n8 MB; over it the answer is 413 before a byte is kept. rscKit({ maxActionBody })\nraises it. For large files, mint a pre-signed url and upload straight to storage.`,\n },\n {\n topic: 'data',\n summary: 'Loading data, streaming it, and when the browser needs to refetch',\n body: `**In a server component, just await it.** No loader, no getServerSideProps.\n\n\\`\\`\\`tsx\nexport default async function Page() {\n const posts = await db.posts.all()\n}\n\\`\\`\\`\n\n**Better: do not await.** Pass the promise down and let a client component\nresolve it — the shell paints at once and the rows stream into the same\nresponse, with no request from the browser:\n\n\\`\\`\\`tsx\nexport default function Page() {\n const posts = getPosts() // not awaited\n\n return (\n <Suspense fallback={<Skeleton />}>\n <List posts={posts} /> {/* 'use client': use(posts) */}\n </Suspense>\n )\n}\n\\`\\`\\`\n\nReach for this first. It is the thing RSC is for.\n\n**When the BROWSER decides to refetch** — a filter, another page, a refresh —\ncall fetchQuery. It is a plain async function that returns the typed answer;\nNO library is needed:\n\n\\`\\`\\`tsx\nconst [listings, setListings] = useState(initial) // the server-rendered value\nconst [pending, start] = useTransition()\nconst show = (kind) => start(async () => setListings(await fetchQuery(getListings, [kind])))\n\\`\\`\\`\nWorks in an onClick, onSubmit, useEffect - anywhere in the browser.\n\n**When you want CACHING** (stale-while-revalidate, dedupe, offline), hand the\nsame call to the library that holds the answer; this package ships none:\n\\`\\`\\`tsx\nuseQuery({ queryKey: ['posts', kind], queryFn: () => fetchQuery(getPosts, [kind]) })\nuseSWR(['posts', kind], () => fetchQuery(getPosts, [kind]))\n\\`\\`\\`\nfetchQuery sends the read as a GET and goes to the server every time, which\nis what a fetcher needs. It will never cache, dedupe or batch: a client\ncache is the library's job, and a batch would lose the per-read cache key a\nGET has. The ladder, most reads stopping on the first rung:\n 1. the value now: fetchQuery + setState\n 2. survive a reload / let a CDN serve it: query(fn, { cache: 'public', maxAge })\n - HTTP caching, no code in the page\n 3. staleness, background refresh, optimistic updates, shared across\n components: TanStack or SWR with fetchQuery as the fetcher\nDo not add a cache on top of it, and do not install TanStack for a single\nbutton that reads once.\n\nKeep the arrow: TanStack calls a bare \\`queryFn\\` with its own context, and a\nserver function serialises whatever it is handed.\n\nA value that keeps CHANGING while someone watches - polling, SSE, realtime -\nis how_to live-data, not this.`,\n },\n {\n topic: 'suspense',\n summary: 'Where boundaries go, and why the build cares',\n body: `A boundary is what lets a page be stored with a hole in it rather than not\nstored at all.\n\n\\`\\`\\`tsx\n<Suspense fallback={<Skeleton />}>\n <Slow />\n</Suspense>\n\\`\\`\\`\n\nOr a \\`loading.tsx\\` beside the page, which is the same thing for the whole\nroute.\n\nThe build renders every page. Whatever has not resolved when the budget expires\nbecomes the hole; everything above it is stored and served instantly. So a page\nwith no boundary above its slow part cannot be stored at all — the build says\nso:\n\n ƒ /orders\n blocks before anything can paint. Add a loading.tsx beside it, or put a\n <Suspense> above the waiting, and it has a skeleton to store.\n\nA boundary does NOT fix a frozen \\`Date.now()\\`. Prerendering renders straight\nthrough a component that never awaits, so the value is captured exactly as\nbefore. A boundary becomes a hole only when something inside it waits.\n\nredirect() and notFound() inside a boundary still work: the shell has gone\nout, so the redirect travels in the row's error digest and the browser\nperforms it as a navigation, layouts kept. An error.tsx on the route never\nsees it - a redirect is the page's answer, not a failure - and the same holds\nfor a component under its own <Suspense> and for a parallel route slot. Only\nan authorization check should NOT be there: the layouts above already\nrendered. See the redirects guide.`,\n },\n {\n topic: 'offline',\n summary: 'Service worker, and what it does and does not cache',\n body: `\\`\\`\\`ts title=\"vite.config.ts\"\nrscKit({ offline: true })\n\\`\\`\\`\n\nThe build writes a service worker that precaches the client bundle and caches\npages at runtime — a document fetch warms its payload, a payload fetch warms its\ndocument, so a page reached by a link still works when reloaded offline.\n\nPages the build stored whole are served from the cache FIRST, because they\ncannot change until a deploy and a deploy sweeps the cache. Everything else is\nnetwork-first with the cache as fallback.\n\nNothing marked \\`no-store\\` is ever kept — which is how a guarded page and a\nsession-reading query stay out of a cache that has no notion of who asked.\n\nIn a component:\n\n\\`\\`\\`tsx\nimport { useOnline } from '@rsc-kit/core/useOnline'\n\nconst online = useOnline()\n\\`\\`\\`\n\nThere is no push and no background sync. Push needs a subscription endpoint and\na sender; background sync needs idempotent replay. Both are the app's decisions.\n\nEvery document carries a Link header naming its stylesheet, client entry\nand preloaded fonts (stored documents included); Cloudflare sends it as 103\nEarly Hints. Nothing to configure; do NOT add rel=preload headers or a\n_headers file for these in the app.\n\nAcross a deploy: the worker serves the previous build's document first, so a\nreturning visitor's first navigation reaches the new server from the old\nclient. Handled: every document carries its build (<meta name=\"rsc-kit:build\">),\nthe client sends it as X-RSC-Version, the server answers 409 to another\nbuild and the client loads the document; once the worker has announced a\nnew build (useAppUpdate), the next navigation is a document load; a payload\nnaming a client reference the page lacks reloads like a missing chunk. Do\nNOT add prefetch={false} or reload logic in the app for \"client reference\nnot found\" - it is the package's, and fixed.`,\n },\n {\n topic: 'pwa',\n summary: 'Making the app installable',\n body: `A manifest file beside the routes:\n\n\\`\\`\\`ts title=\"src/app/manifest.ts\"\nimport type { WebManifest } from '@rsc-kit/core/manifest-file'\n\nexport default {\n name: 'Orders',\n shortName: 'Orders',\n themeColor: '#0b0b0c',\n backgroundColor: '#ffffff',\n} satisfies WebManifest\n\\`\\`\\`\n\nRead at build time, so it must be an object literal — not computed, not\nimported from elsewhere.\n\n**Icons need no listing.** Put them in \\`src/app/\\` and the build finds them:\n\n favicon.ico served at /favicon.ico\n icon-192.png <link rel=\"icon\">, and the manifest's icons\n icon-512.png\n apple-icon.png <link rel=\"apple-touch-icon\">\n opengraph-image.png <meta property=\"og:image\">\n twitter-image.png <meta name=\"twitter:image\">\n\nSizes are read from the filename. The build says whether it worked:\n\n [rsc-kit] manifest: Orders is installable\n [rsc-kit] manifest: no icons, so no browser will offer to install this.\n\nThere is no layout to edit — React hoists the tags into <head>.\n\nPair it with \\`offline: true\\`. They are separate options because they are\nseparate decisions.\n\n**Push and background sync** need listeners the generated worker does not have,\nso it imports yours from \\`src/app/sw.js\\` — plain javascript, evaluated by the\nbrowser with no build step in front of it:\n\n\\`\\`\\`js\nself.addEventListener('push', (event) => {\n const payload = event.data ? event.data.json() : {}\n\n event.waitUntil(self.registration.showNotification(payload.title, { body: payload.body }))\n})\n\\`\\`\\`\n\nThe rest is the web api and \\`web-push\\`, not this package: VAPID keys, a\nsubscribe call behind a button, the subscription stored by a server action\nagainst a USER rather than a session, and a sender that deletes an endpoint on\n404 or 410 rather than retrying a dead one forever.\n\nFor background sync, make the endpoint idempotent. The browser decides when a\nsync runs and may run it more than once — a request that reached the server\nwhose response did not arrive is retried, and if that posts a message twice the\nperson sent it twice.`,\n },\n {\n topic: 'no-javascript',\n summary: 'The default is none; \"use client\" is how a page asks for it',\n body: `There is NO JavaScript on a stored page until something on it needs some.\nA route that freezes whole, renders none of the app's client components, and\nhas no server action in its tree ships nothing - no React, no router. The\nbuild says so:\n\n ○ /about no js\n no client components, so ships no javascript; stylesheet inlined\n\n\"use client\" IS the opt-in. Put a client component on the page - a counter, a\n<Link>, an update prompt - and it has the runtime, because there is now\nsomething for the runtime to do. There is NO switch in either direction:\nnothing can need the runtime without a client component or an action in the\ntree, and a page that must stay this way is an assertion on build-report.json\n(its clientJs is null), not a setting.\n\nThe check reads the rendered tree, so a <Link> in a shared layout counts -\npages under a layout with a nav keep the runtime; a route group with its own\nplain layout drops it. Navigation into such a page from a Link elsewhere\nstill works: its flight payload is written with the wrappers.\n\nA page without the runtime also gets its stylesheet inlined when small\n(rscKit({ inlineStylesheets }) to change), and still registers the service\nworker with one inlined line.\n\nDo NOT restructure an app to chase this, and do NOT look for export const\nclientJs - it does not exist. The size column says what each route costs; a\npage that is 82 kB because of one <Link> is fine.`,\n },\n {\n topic: 'api-routes',\n summary: 'HTTP endpoints beside the pages',\n body: `\\`src/app/**/route.ts\\`, one export per method:\n\n\\`\\`\\`ts title=\"src/app/api/posts/[id]/route.ts\"\nimport type { RouteContext } from '@rsc-kit/core/route-schema'\n\nexport const params = z.object({ id: z.coerce.number().int() })\nexport const body = z.object({ title: z.string().min(1) })\n\nexport async function GET(request: Request, { params }: RouteContext<typeof params>) {\n const { id } = await params\n\n return Response.json(await findPost(id))\n}\n\nexport async function POST(request: Request, { params, body }: RouteContext<typeof params, never, typeof body>) {\n const { title } = await body\n\n return Response.json(await createPost(title), { status: 201 })\n}\n\\`\\`\\`\n\nTYPE THE CONTEXT: RouteContext<'/api/items/[id]'> types params from the\nroute's segments (the pattern is checked against the routes the build found);\nRouteContext<typeof params> from a schema. Every field is a PROMISE - a sync\nparams.id is a compile error, never a route that 404s. Do NOT hand-write\n{ params: { id: string } }.\n\nA real \\`Request\\` in, a real \\`Response\\` out. \\`params\\`, \\`searchParams\\` and\n\\`body\\` are awaited, the same way a page's props are.\n\nFetch one through \\`apiUrl\\` and the path is checked against the routes the\nbuild found:\n\n import { apiUrl } from '@rsc-kit/core/routes'\n await fetch(apiUrl('/api/posts/' + id))\n\nA route.ts is a Route too (type Route from '@rsc-kit/core/routes' covers pages\nAND route.ts files, as Next's does): Link, visit and redirect accept it. The\nclient treats a link to a route as an anchor - never prefetched, a full\nnavigation, not a payload fetch. ApiRoute is the narrower union for apiUrl:\napiUrl refuses a page, because fetching one gets html. It checks the PATH, not\nthe response type - for types across the boundary use a server action or a\nquery, where the return type is the function's because it is the same function.\n\nThey run their directory's \\`middleware.ts\\`, so an endpoint under a guarded\npath is guarded.\n\nA \\`GET\\` that reads nothing from the request is answered from disk. Awaiting\n\\`searchParams\\` says the answer depends on the query; never touching it means\nthe stored answer is served for any query at all. NEVER read the query with\nnew URL(request.url).searchParams (the Next way): the build cannot see that\nread, and reading request.url at all makes the route dynamic (the table says\n\"reads the request - url\"). A webhook verification handshake (hub.mode,\nhub.challenge) reads the awaited searchParams. A GET that answers 4xx/5xx to\nthe build is never stored either (the table says \"answered 403 to the build\").\n\nredirect() thrown from a handler is the route's answer: a real 3xx Location\nfor whoever asked (a signed-url export, a moved endpoint); notFound() is its\n404. A guard's redirect above a route.ts is a refusal: a browser that\nnavigated there gets the Location, code that fetched it gets 401 +\nX-RSC-Redirect (fetch would follow a Location and hand back the login page).\n\nExporting a \\`body\\` schema consumes the stream, so \\`request.json()\\` inside the\nhandler will find it already read. Use the parsed value.\n\nA GET that reads nothing is stored - and two more things keep one per request:\na Set-Cookie on its Response (an answer for one visitor; classified dynamic,\nwhatever it read), and a body it froze from Date.now() or Math.random() is\nstored WITH a warning on the route's line. Read the request (await\nconnection()) to run it on demand.`,\n },\n {\n topic: 'authorization',\n summary: 'Guarding pages, actions, api routes and queries',\n body: `Each entry point defends itself. There is no single place that covers all of\nthem, and believing otherwise is how a hole is left.\n\n**A page or an api route**: \\`middleware.ts\\` in its directory guards everything\nat or below it.\n\n\\`\\`\\`ts title=\"src/app/admin/middleware.ts\"\nimport { redirect } from '@rsc-kit/core/redirect'\n\nexport default async function guard() {\n if (!(await currentUser())) redirect('/login')\n}\n\\`\\`\\`\n\nSeveral checks in one directory: export default [signedIn, verified, admin] -\nrun in order, stopping at the first refusal; reuse a check by importing it\nfrom one place. Directories still compose outermost first.\n\n**An action or a query**: middleware does NOT run — they render no route. Build\nthem from an action client so the check cannot be forgotten. See the\n\\`action-client\\` topic.\n\n**Authorise on identity, not arguments.** \\`deletePost(id)\\` that trusts the id\nis the whole of an IDOR: the caller chooses the id, so check the row belongs to\n\\`ctx.user\\`.\n\nA guarded page can still be frozen at build time — the guard is a serving\ndecision, not a build one. Its response is marked private so no cache keeps it.`,\n },\n {\n topic: 'dynamic',\n summary: 'Why a page is not static, and how to choose',\n body: `A page is stored at build time unless it reads the request. Reading it is what\nopts out, and the accessors are async:\n\n\\`\\`\\`ts\nimport { cookies, headers, searchParams, connection } from '@rsc-kit/core/request'\n\nconst theme = (await cookies()).get('theme')?.value // { name, value } | undefined, as in Next\nawait connection() // \"render this per visitor\", said deliberately\n\\`\\`\\`\n\nA page's \\`params\\` and \\`searchParams\\` props are promises for the same reason.\n\nThe build says which call did it, per route:\n\n ◐ /locale 85 kB\n cookies(), headers() stream per request; the rest is stored\n\nThat is usually correct — a page whose content depends on who is asking cannot\nbe one stored file. Change it only when the read was accidental.\n\nFor a parameterised route, \\`generateStaticParams\\` turns one shell into a page\nper url:\n\n\\`\\`\\`ts\nexport async function generateStaticParams() {\n return (await db.posts.all()).map((p) => ({ slug: p.slug }))\n}\n\\`\\`\\`\n\nA value that must differ per visitor but needs no server — a clock,\nlocalStorage, a map — belongs in the browser only:\n\n\\`\\`\\`tsx\n'use client'\nimport { browser } from 'react-dom'\n\nfunction Clock() {\n use(browser('the time is the visitor\\\\'s, not the build machine\\\\'s'))\n}\n\\`\\`\\`\n\nIt needs a Suspense boundary, and the page stays frozen.\n\nThere is NO app-wide switch to turn prerendering off (no rscKit({ prerender })\nand no export const dynamic). A page that must render per request says\nawait connection() in the page; the build names any page it could not render\nat build time, and that is the page to mark. \"All of it per request\" is\nawait connection() in the root layout with a root loading.tsx - every page a\nstored fallback with the rest streamed, five times slower on a page that could\nhave been stored (bench/ in the repo). Do not reach for it.`,\n },\n {\n topic: 'metadata',\n summary: 'Titles, share cards, and the one setting production needs',\n body: `\\`\\`\\`tsx\nexport const metadata: Metadata = {\n title: 'Orders',\n openGraph: { title: 'Orders', description: '…', images: '/cover.png' },\n}\n\\`\\`\\`\n\nA layout takes a title TEMPLATE - { template: '%s · Site', default: 'Site' } -\nand layouts merge outward-in, so site-wide values go on the root layout once.\n\n**Set metadataBase on the root layout. It is not optional in production.**\n\n\\`\\`\\`tsx\nmetadataBase: new URL('https://example.com')\n\\`\\`\\`\n\nA share-card scraper needs an ABSOLUTE image url and Facebook, Slack and\nLinkedIn refuse a relative one silently - the link unfurls with no image and\nnothing says why. metadataBase makes every relative url, image and icon\nabsolute. Same name as Next, so a port carries it across.\n\nUse the structured objects, not the flat 'og:title' spellings: openGraph and\ntwitter are typed, an image can be { url, width, height, alt }, and it is the\nshape a Next app already has. og: renders as property=, twitter: as name= -\nwhat each scraper reads.\n\nAn opengraph-image.png in app/ is found by name and needs no listing; it still\nneeds metadataBase to go out absolute.\n\nCharset and viewport: every document gets <meta charSet=\"utf-8\"> and\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"> as Next\nwrote them - do NOT add them to a ported layout to fix a desktop layout on a\nphone; a layout that renders either itself is left alone. To change it:\nexport const viewport: Viewport (from @rsc-kit/core/metadata) on a layout or\npage, Next's shape - width, initialScale, maximumScale, userScalable,\nviewportFit, themeColor (string or [{ media, color }]), colorScheme - merged\nouter to inner then the page.`,\n },\n {\n topic: 'fonts',\n summary: 'Self-hosted fonts from npm, and porting next/font',\n body: `There is no font loader. Install the font from Fontsource, import its\ncss, name it in a variable:\n\n\\`\\`\\`css\n@import '@fontsource-variable/fraunces/full.css';\n@import '@fontsource-variable/geist';\n\n:root {\n --font-display: 'Fraunces Variable', ui-serif, Georgia, serif;\n --font-sans: 'Geist Variable', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,\n 'Helvetica Neue', Arial, sans-serif,\n 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';\n}\n\\`\\`\\`\n\nPut the font in FRONT of a full stack, not in place of one. A bare\n'Geist Variable', sans-serif drops the emoji fonts - Geist has no emoji glyphs,\nand with nothing named after it some systems draw a box - and drops the\nmetrics-matched fallback that makes the swap moment smaller. Those are\nTailwind's own defaults; shadcn's generated line loses both.\n\nVite hashes the woff2 files and serves them with the other assets. Nothing is\nfetched from Google at runtime and nothing is downloaded at build - the files\nare in node_modules.\n\nPorting next/font: every option was something Fontsource already did.\nsubsets -> every subset ships behind a unicode-range and the browser fetches\nonly what the page uses. style: ['italic'] -> full-italic.css. axes -> full.css\nhas every axis; standard.css is weight only. display: 'swap' -> already in\nevery rule. className={font.variable} -> nothing, the variable is on :root.\n\nThe one line next/font added that you add yourself is the preload:\n\n\\`\\`\\`tsx\nimport fraunces from '@fontsource-variable/fraunces/files/fraunces-latin-full-normal.woff2?url'\n<link rel=\"preload\" href={fraunces} as=\"font\" type=\"font/woff2\" crossOrigin=\"anonymous\" />\n\\`\\`\\`\n\n?url is Vite's and gives the hashed path. Preload the one file the first paint\nneeds; preloading all of them defeats the subsetting.\n\nDo NOT reach for next/font, @next/font or a Google Fonts link tag.\n\nGETTING TO 100 ON A PHONE (do it last, once the design is settled). Fontsource's\nstylesheet declares every subset of every weight with font-display: swap; a\nthrottled phone audit sees the largest text repaint when the font lands, and\nscores 99. Three moves:\n1. Own the @font-face rules, latin only: import the files with ?url, declare\n them yourself with the latin unicode-range.\n2. font-display: optional for the body faces - the text paints once, in the\n fallback on a cold slow load, in the web font when cached. Keep swap for the\n headline face, which is preloaded and arrives with the document.\n3. One weight per family; variable only where the axes are used. Declare the\n range you ask for (font-weight: 400 500) so nothing requests a missing file.\nPut the preloads before the <style> with the faces. Measured on rsc-kit.dev:\nSpeed Index 1.7s to 0.9s, 99 to a steady 100, fonts 114 kB to 74 kB.\nTHE RULE: a font never blocks the page - text paints in the fallback before\nthe web font arrives. Three ways to break it, all avoided: a fonts.googleapis\n<link> (render-blocking CSS from a cold origin - self-host via Fontsource\ninstead), font-display: block or unset (invisible text for up to 3s - every\nrule says swap or optional), preloading every file (preload only what the\nfirst paint needs).\n\nAFTER THE ANSWER: after(() => sendEmail(user)) from @rsc-kit/core/request\nqueues work to run once the response is on its way - from an action, a\ncomponent, middleware or an api route. Do NOT use a detached promise: on a\nWorker the isolate dies with the response unless work is handed to\nwaitUntil, which after() does; on a process it runs detached. Rejections\nare logged, never surfaced.\n\nWRITE THE SCHEMA FOR THE SHAPE IT WANTS. The form is read the way the schema\nmeans it, on both sides (Zod 4 / ArkType describe themselves as JSON Schema;\nValibot not yet - its values arrive as strings):\n notify: z.boolean() // unchecked posts nothing -> false; \"on\" -> true\n limit: z.number().int() // \"5\" -> 5. NOT z.coerce.number()\n tags: z.array(z.string()) // one -> ['a'], none ticked -> []\n policy: z.string().optional() // hidden behind a switch -> absent when off\n + .refine((s) => !s.notify || s.policy) for \"required when the switch is on\"\nNested names nest: fields[0][name] / fields[0].name -> { fields: [{ name }] };\nauth[kind] picks a discriminated union's branch. No per-checkbox transform,\nno checkbox() helper. The action decodes the same object the form validated.\nOffline (rscKit({ offline: true })): the precache is what boots the app - js,\ncss, fonts, manifest, icons, / and /offline with their boot payloads; images,\nwasm and the share card are cached on first use. A frozen page never visited\nfalls back to /offline like any other navigation. Do not add a second service\nworker or a precache list; app/sw.js is importScripts'd into this one.\n\nAn action that redirect()s RESOLVES with { redirected: '/where' } on the\nclient once the navigation starts - it does not throw, so a plain\nstartTransition(async () => await logOut()) needs no catch (a rejection\nthere unmounts the root). The result is always an object (exactly one of\ndata / validationErrors / serverError / redirected is set), so reading any\nfield of it is safe. Do NOT wrap actions in a hook to catch\nServerRedirectError; nothing throws.\n\nBefore hydration a submit is a native POST to the page's url (React's hidden\n$ACTION_ fields); the host runs the action and re-renders the page with the\nresult seated in the <Form> that posted - a refusal shows on its fields\nwithout javascript; a redirect() is followed, a cookie lands. Nothing to\nconfigure; <Form> uses useActionState under a wrapper so the action keeps\nits (formData) signature.\nA blank control is absent for any optional field (z.email().optional()\naccepts it; an optional union is not read as its first branch) and \"\" for a\nrequired one (z.string().min(1) refuses it). A leaf\nwith no JSON Schema (z.date()) arrives as posted; its siblings still coerce.\n<Form ref={...}> is fine: the caller's ref is filled beside the form's own.\nRender props also carry dirty (anything differs from mount, uncontrolled\nfields included; baseline moves on a successful submit; reset() clears it):\n{({ dirty, reset }) => <Button disabled={!dirty}>Save</Button>} - the RHF\nisDirty gate for Save/Discard.\n\nFull guide: read_guide({ slug: 'fonts' }).`,\n },\n {\n topic: 'from-next',\n summary: 'Porting a Next.js app - what carries over, what to rename, what is different on purpose',\n body: `The app/ conventions are the same: layout, page, loading, error, not-found,\nroute.ts, [slug], [...path], (group), @slot, (.)intercept. \"use client\" and\n\"use server\" are React's. Copy src/app first, fix imports second.\n\nIMPORTS\n next/link -> @rsc-kit/core/Link (href typed; search typed by the page's schema)\n useRouter().push / .replace -> visit(url) / visit(url, { replace: true }) from @rsc-kit/core/router\n useRouter().refresh() -> refresh() from @rsc-kit/core/router, or revalidate() in the action\n usePathname / useSearchParams-> @rsc-kit/core/usePathname, @rsc-kit/core/useSearchParams (nuqs: @rsc-kit/core/nuqs)\n useParams() -> the page's params prop, passed down\n cookies(), headers() -> same names, from @rsc-kit/core/request. A cookie set in an action is what\n get()/has()/getAll() answer for the rest of that request, the sections it\n revalidates included - set('agent', id) then revalidate('all') is a whole switcher\n redirect() / notFound() -> @rsc-kit/core/redirect / @rsc-kit/core/not-found\n revalidatePath/Tag -> revalidate('name') on a section() - targeted, rides back with the action; the\n name is typed to the sections and slots the build found\n Metadata, Viewport -> @rsc-kit/core/metadata (metadataBase, openGraph, twitter, icons as-is;\n export const viewport as-is; charset + viewport tags are written for you)\n app/robots.ts, app/sitemap.ts -> the same files and shapes; app/llms.ts beside them (how_to seo-files)\n middleware.ts subdomain rewrite -> nothing: a host is a route segment (how_to domains)\n flags/next (Vercel Flags SDK) -> unchanged: the build aliases next/headers to @rsc-kit/core/request\n (how_to feature-flags); precompute() does not carry over\n next/font -> Fontsource (how_to fonts)\n next/image -> unpic or vite-imagetools (how_to images)\n next/script -> a <script> tag (how_to scripts)\n NEXT_PUBLIC_* -> PUBLIC_* in src/env.ts (how_to env); server vars typed there too\n import 'server-only' -> keep it (the build honours it). Under bun test the real package throws on\n import, so the scaffold's tests/preload.ts stubs it: bunfig.toml\n [test] preload = [\"./tests/preload.ts\"], mock.module('server-only', () => ({})).\n A project without those two files adds them before unit-testing an action.\n next-safe-action -> createActionClient() (how_to action-client); returnValidationErrors -> return fieldErrors({...})\n cache from 'react' -> cache from @rsc-kit/core/cache: React's dedupes only inside a render; this one\n spans the request (guards, actions, api routes). The build names files still on React's\n @react-email/render, renderToString in an action -> the same call, in a module that starts with \"use ssr\"\n (the build warns naming the app file and the package; the stub throws when called)\n (how_to emails). Next gets away with it only for externalised packages; here it is explicit\n import type { Route } from 'next' -> import type { Route } from '@rsc-kit/core/routes' (pages AND route.ts; a link to a route.ts is an anchor, never prefetched)\n\nDIFFERENT ON PURPOSE\n- No export const dynamic / revalidate = 60. A page is frozen unless it READS\n the request; await connection() is the explicit mark. No time-based ISR.\n- middleware.ts is per directory, on the server, full API; not one edge file.\n It does not run for actions - the check goes in the action.\n- Actions return failures ({ validationErrors }, { serverError }), not throw.\n- No image optimizer, no opengraph-image.tsx - put opengraph-image.png in src/app.\n- Tests need no browser: createTestApp() is the deployed handler. It builds\n with the project's own build script on the runtime the tests run under, and\n answers files the build wrote to .output/public (assets, sw.js, the\n manifest, icons) as production does - app.fetch('/sw.js') is a real test.\n- A component library (base-ui, Radix) imports as it did, from server\n components too. A shadcn-style components/ui/ folder keeps \"use client\" at\n the top of each file, as shipped; without it the server evaluates the\n library's internals for nothing.\n\nSCAFFOLD FLAGS: --host=bun|node|worker --validation=zod|valibot|arktype|none\n--env/--no-env (typed env vars via @t3-oss/env-core in src/env.ts, in the\nchosen library; server vars never reach the browser, PUBLIC_ prefix for ones\nthat may). Pick the library the Next app already uses.\n\nORDER: scaffold -> copy src/app -> fix imports -> build (it typechecks first,\nso a Link to a route that does not exist fails here) and READ the output: a\nroute that is not ○ names what streams and from which component (a cookies()\nin a layout reaches every page; the build says so) -> decide each action the\nbuild lists as running no middleware -> check.\n\nCONVERT THE FORMS AND ACTIONS - do not carry them. useActionState +\nuseFormStatus, react-hook-form, TanStack Form and useState-per-input all\nstill COMPILE here, which is why a port leaves them. Each becomes\n<Form action={…} schema={…}> (how_to forms) and a createActionClient()\nhandler (how_to action-client). Remove the form library when the last form\nis converted. A port that keeps two form systems has ported nothing.\n\nFull guide: read_guide({ slug: 'coming-from-next' }).`,\n },\n {\n topic: 'from-inertia',\n summary: 'Porting a Laravel + Inertia React app - what stays in Laravel, what the pages become, what goes',\n body: `Laravel stays, all of it: models, policies, form requests, middleware, jobs,\nthe session. What changes is who renders the page: Inertia rendered it in\nthe browser from props a controller assembled; here the page is a server\ncomponent rendered in front of Laravel that reads by name with rpc().\nInstall: composer require rsc-kit/laravel && php artisan rsc:install\n(runs rsc-kit init; one vite.config.ts, laravel-vite-plugin moved aside;\nroute tree is resources/js/app). See how_to backend for rpc(), attributes,\nmake:rsc-action and the manifest.\n\nWHAT EACH PIECE BECOMES\n resources/js/Pages/Orders/Index.jsx -> resources/js/app/orders/page.tsx (the file is the route)\n Route::get + Inertia::render(...props) -> nothing: the controller body moves to app/Rsc/Orders.php::recent(),\n the page does await rpc<Order[]>('Orders.recent', 20). Delete the route.\n usePage().props.auth.user, share() -> a read in the layout that shows it: rpc('Shared.auth'), passed as props.\n No global props bag. Deepest layout that needs it - a read in the root\n layout makes every page render per request (the build says so).\n usePage().props.flash -> the action's return value, back on the form that submitted\n <Link href={route('x', id)}> (Ziggy) -> <Link href=\"/orders/1\"> from @rsc-kit/core/Link, typed to the tree. Drop Ziggy.\n router.visit / router.get -> visit(url) from @rsc-kit/core/router\n router.reload({ only }) -> Rsc::revalidate('orders') in the action re-renders that section with the answer\n router.post, useForm().post('/orders') -> a server action: php artisan make:rsc-action Orders --method=create --auth\n --revalidate=orders, then <Form action={ordersCreate}> from @rsc-kit/core/form\n (import ordersCreate from the generated actions module)\n useForm errors/processing -> <Form>'s errors (a FormRequest's ValidationException lands on the fields)\n and pending\n ->middleware(['auth','verified']) -> export const middleware = ['auth', 'verified'] in middleware.ts beside the page\n Inertia::defer, <WhenVisible>, lazy() -> <Suspense> around the component that awaits; the shell streams first\n <Head title> -> export const metadata = { title }\n Page.layout = ... (persistent layouts) -> layout.tsx in the directory\n app.blade.php @inertia @vite -> resources/js/app/layout.tsx with <html><body>; no Blade root view\n createInertiaApp(), ssr.jsx, start-ssr -> nothing; init writes the entry; every page renders on the server\n usePoll -> usePolling (how_to live-data)\n <Link prefetch> -> the default (hover)\n Inertia::render('Error') -> error.tsx, not-found.tsx\n RedirectResponse from a controller -> redirect() from @rsc-kit/core/redirect in the page or guard;\n RscRedirectException from PHP is performed by the browser\n NProgress -> view transitions, or nothing\n\nAuth pages: Auth.login with a LoginRequest calling Auth::attempt() - a cookie\nqueued during the call lands on the response; redirect('/dashboard') in the\nguard sends a signed-in visitor on. /login can also stay Laravel's (Fortify):\nany url the tree does not have is forwarded.\n\nDIFFERENT ON PURPOSE: no controller per page (a page reads three things,\ncalls three methods); no props bag and no partial reloads (sections\nrevalidate); / is the React tree's, welcome route or not; two processes\n(php artisan serve needs PHP_CLI_SERVER_WORKERS=4 and --no-reload); the\nbuild says what each page costs - most pages that read nothing per visitor\nfreeze, which Inertia never could.\n\nORDER: install -> move Pages/ into app/ one directory per url, layouts to\nlayout.tsx -> per page, controller body into app/Rsc, rpc() it, delete the\nroute -> npm run build and READ the output (a read in the root layout\nreaches every page; move it down) -> CONVERT THE FORMS (each useForm is a\nmake:rsc-action + <Form>; useForm still compiles here, which is why a port\nleaves it) -> replace route() with typed hrefs; tsc finds the rest -> build\nagain -> browser. Drop @inertiajs/react, laravel-vite-plugin and ziggy from\npackage.json when the last import is gone.\n\nFull guide: read_guide({ slug: 'coming-from-inertia' }).`,\n },\n {\n topic: 'feature-flags',\n summary: \"Vercel's Flags SDK (flags/next) runs unchanged: next/headers is answered by headers()/cookies() here\",\n body: `bun add flags. Then flags/next as written for Next:\n\n import { flag, dedupe } from 'flags/next'\n const visitor = dedupe(async ({ cookies, headers }) => ({ id: cookies.get('visitor')?.value ?? 'anon' }))\n export const showBanner = flag<boolean, { id: string }>({ key: 'show-banner', identify: visitor, decide: ({ entities }) => entities?.id === 'ada' })\n\n // page.tsx (server component)\n const on = await showBanner()\n\nThe build aliases next/headers to @rsc-kit/core/request - same names, same\nshapes (cookies().get(name)?.value), one object per request, which the SDK's\ndedupe keys on. Nothing to configure, no shim to write.\n\nA flag reads the request, so the page renders per visitor: put a <Suspense>\nor loading.tsx above the read and the build stores the rest as a shell (the\ntable says \"headers() in run, cookies() in run stream per request\").\n\nDiscovery endpoint: a route.ts -\n export const GET = createFlagsDiscoveryEndpoint(async () => getProviderData(flags))\n export const openapi = false\nprecompute() does NOT carry over (it rewrites urls in Next middleware); read\nthe flag in the page.`,\n },\n {\n topic: 'emails',\n summary: 'Render React to HTML on the server - an email, a PDF, a feed - from an action or a route, with \"use ssr\"',\n body: `@react-email/render, renderToString, anything on react-dom/server, called from\na server action or a route, fails: \"react-dom/server is not supported in React\nServer Components\". React means it: where server components render, react is\nthe server-only build - the renderer needs the client build's internals, and\nthe components it would render import that same react (no useState, no\nuseContext). No alias fixes it. The rendering has to run in the ssr\nenvironment, the one that turns pages into HTML for the browser.\n\nPut the rendering - the template AND the call that renders it - in a module\nthat starts with \"use ssr\". Everything else imports it normally:\n\n\\`\\`\\`tsx\n// src/lib/email/render.tsx\n\"use ssr\";\nimport { render } from '@react-email/render'\nimport { OtpEmail } from './otp-email'\n\nexport async function renderOtpEmail(code: string) {\n const email = <OtpEmail code={code} />\n const [html, text] = await Promise.all([render(email), render(email, { plainText: true })])\n return { html, text }\n}\n\\`\\`\\`\n\n\\`\\`\\`ts\n// src/lib/email/send-otp.ts - a plain server module, called from the action\nimport { renderOtpEmail } from './render'\nexport async function sendOtpEmail(to: string, code: string) {\n const { html, text } = await renderOtpEmail(code)\n await transporter.sendMail({ to, subject: 'Your code', html, text })\n}\n\\`\\`\\`\n\nWhere server components render, the build replaces the module with async\nproxies of its exports that call across - what \"use client\" does for a\ncomponent, in the other direction. Same process; dev and build; nothing to\nconfigure.\n\nRULES\n- Exports are async functions. The call crosses environments, so the answer is\n a promise. A sync function, a value, a class, export { } or export * is\n refused at build with its name. Types are fine.\n- Pass DATA across, not elements: renderOtpEmail(code), never\n render(<OtpEmail/>) from the caller. An element built on the calling side\n carries components from that side's react, and they render with no hooks.\n- The module's imports are the ssr side's: @react-email/components,\n react-dom/server, a PDF or Markdown renderer. Keep the module to rendering;\n the database call belongs on the calling side.\n\nImported react-dom/server directly (through a library, usually)? It now throws\nthe fix in its message, naming the app file that pulled it in, and the build\nwarns once with the same. Do NOT alias react-dom/server, externalise react, or\nmove the action out of the app - the directive is the whole fix.\n\nFull guide: read_guide({ slug: 'emails' }).`,\n },\n {\n topic: 'seo-files',\n summary: 'robots.txt, sitemap.xml and llms.txt from a file beside the root layout - the shapes Next uses, stored at build when they can be',\n body: `Files beside the root layout, named for what they answer:\n src/app/robots.ts -> /robots.txt default export returns MetadataRoute.Robots\n src/app/sitemap.ts -> /sitemap.xml default export returns MetadataRoute.Sitemap (an array)\n src/app/llms.ts -> /llms.txt default export returns MetadataRoute.Llms\n src/app/llms-full.ts -> /llms-full.txt default export returns a string\nThe types: import type { MetadataRoute } from '@rsc-kit/core/metadata'. The\nsame names and shapes as Next's app/robots.ts and app/sitemap.ts; copy them.\n\n\\`\\`\\`ts\n// src/app/robots.ts\nexport default function robots(): MetadataRoute.Robots {\n return { rules: [{ userAgent: '*', allow: '/', disallow: ['/api/'] }], sitemap: '/sitemap.xml' }\n}\n// src/app/sitemap.ts\nexport default async function sitemap(): Promise<MetadataRoute.Sitemap> {\n const posts = await db.post.findMany()\n return [{ url: '/', priority: 1 }, ...posts.map((p) => ({ url: \\`/blog/\\${p.slug}\\`, lastModified: p.updatedAt }))]\n}\n// src/app/llms.ts\nexport default function llms(): MetadataRoute.Llms {\n return { title: 'Acme', summary: 'What it is.', sections: [{ title: 'Pages', links: [{ title: 'Pricing', url: '/pricing' }] }] }\n}\n\\`\\`\\`\n\nA relative url is made absolute with the root layout's metadataBase; without\none it is a build error. Any of them may return a string, served as written.\n\nNO sitemap.ts? The build writes /sitemap.xml itself: every stored page and\nevery generateStaticParams url, lastModified = the build, minus guarded\nroutes (middleware.ts above them), failed pages and not-found. Needs the root\nlayout's metadataBase. Write sitemap.ts only when you need urls the build\ncannot see or per-url changeFrequency/priority.\n\nHOW FRESH - the function decides, the same rule every route follows:\n write nothing -> the build's own sitemap, stored; fresh every deploy\n sitemap.ts that reads the database -> stored at build (○); fresh every deploy\n sitemap.ts that awaits connection() -> rendered per request (ƒ); fresh every crawl\n\\`\\`\\`ts\nexport default async function sitemap() {\n await connection() // from '@rsc-kit/core/request' - per request, like a page\n return (await db.post.findMany()).map((p) => ({ url: \\`/blog/\\${p.slug}\\`, lastModified: p.updatedAt }))\n}\n\\`\\`\\`\nReading the database at build is fine; the REQUEST makes it dynamic, not the\ndata. Same for robots.ts and llms.ts. No middleware runs for\nthem - a root guard must not 401 the crawler. The url is typed\n(route('/sitemap.xml')).\n\nA file as written beside the root layout is served at the root as it is:\nrobots.txt, sitemap.xml, sitemap-*.xml, llms.txt, llms-full.txt, humans.txt,\nsecurity.txt, ads.txt. A file and a function for the same url is a build\nerror. Do NOT put these in public/ and do NOT write a middleware.ts for them.\n\nFull guide: read_guide({ slug: 'seo-files' }).`,\n },\n {\n topic: 'domains',\n summary: 'Subdomains and custom domains as route segments - admin.example.com reaches app/admin, a tenant host binds [domain], no rewrite',\n body: `A request from a host that is not the site's own is matched with the host\nin FRONT of the path. The site's own hosts: the root layout's metadataBase,\nwww. of it, and rscKit({ hosts: [...] }) - which is ONLY for a name that is\nneither the apex nor a subdomain of it (a staging/internal name, a second\nbrand domain); a normal setup needs no config beyond metadataBase. localhost\nand ips are always own.\n\n example.com/admin -> /admin app/admin/page.tsx\n admin.example.com/ -> /admin the same file (subdomain of an own host = its label)\n acme.example.com/settings -> /acme/settings app/[domain]/settings/page.tsx, domain \"acme\"\n acme.com/settings -> /acme.com/settings the same file, domain \"acme.com\" (other host = whole host)\n\nThe visitor's url is untouched; only the match changes. A top-level [domain]\nbinds ONLY from a host, never from a path: example.com/nope is a 404, not a\ntenant called \"nope\". Otherwise [domain] is an ordinary dynamic segment: params.domain in every page/layout under it, typed\nroute('/[domain]/settings', { domain }), loading/error files as usual. A\ndirectory named for a host (app/admin/) wins over [domain].\n\n\\`\\`\\`tsx\n// src/app/[domain]/layout.tsx\nexport default async function TenantLayout({ params, children }) {\n const { domain } = await params\n const tenant = await tenantByDomain(domain) // \"acme\" or \"acme.com\", as stored\n if (!tenant) notFound()\n return <TenantProvider tenant={tenant}>{children}</TenantProvider>\n}\n// src/app/[domain]/page.tsx - domains in a database: list them, they are stored at build\nexport async function generateStaticParams() {\n return (await db.tenant.findMany()).map((t) => ({ domain: t.domain }))\n}\n\\`\\`\\`\n\nOnly when a route could answer it (a top-level [domain] directory, or one\nnamed for the host); otherwise the host is the site's own. Keep metadataBase as\nthe production host: localhost and ips are always own, so dev routes by path.\nTo try a tenant locally: curl -H 'X-Forwarded-Host: acme.example.com'\nhttp://localhost:3000/ (or /etc/hosts). Behind a proxy the\nhost is X-Forwarded-Host, then Host. Not for a static export (a file server\nsees no host). Do NOT write a middleware rewrite, do NOT\nread the host in every page - the segment already is the host. The root layout\nneeds metadataBase (or rscKit({ hosts })) or every host is the site's own.\n\nFull guide: read_guide({ slug: 'domains' }).`,\n },\n {\n topic: 'identify',\n summary: 'What a response says about itself - X-RSC-Kit (how it was served, always) and X-Powered-By + a generator tag (what built it, off with identify: false)',\n body: `Every response carries X-RSC-Kit: stored | rendered | shell - a page from a\nfile the build wrote, rendered for this visitor, or a stored shell with its\nholes rendered now. The header to read when a page is slower than expected\n(like X-Nextjs-Cache); a CDN rule or health check can key on it. Names no\nproduct; always sent.\n\nBy default a response also says what built it: X-Powered-By: rsc-kit and\n<meta name=\"generator\" content=\"rsc-kit\"> in every document (BuiltWith,\nWappalyzer). The NAME only, never the version - a version in every response\nis what a vulnerability scanner filters on.\n\nrscKit({ identify: false }) turns off the name (header and tag) for a policy\nthat strips framework identifiers; X-RSC-Kit stays. Do not strip X-RSC-Kit\nat the proxy - it is what tells you whether a stored page was served.\n\nFull guide: read_guide({ slug: 'response-headers' }).`,\n },\n {\n topic: 'backend',\n summary: 'BAP (Backend-Answered Pages): a Laravel, Go or other backend behind the renderer - rpc() reaches it, middleware.ts names its middleware, app/Rsc/Actions are its server actions',\n body: `The model is a BAP - Backend-Answered Pages: a page rendered in front of\nthe backend rather than by it (MPA: backend renders; SPA: browser renders and\ncalls an API; BAP: a renderer on the server renders and calls the backend\nover loopback). The backend is the part that is not a page - models, session,\nauth, policies, jobs - answering one private endpoint, and it keeps every\nroute of its own. The whole model, and how to build for it:\nread_guide({ slug: 'backend-answered-pages' }).\n\nGo: in a Go module, rsc-kit init sees go.mod, writes the JS half and .env\n(RSC_BACKEND + a generated secret) and prints the Go wiring; go get\ngithub.com/rsc-kit/go. A new app: bun create rsc-kit --backend=<url>.\n\nA backend in another language answers that ONE endpoint, POST /__rsc/host-call,\nand the renderer wires itself from two variables in .env: RSC_BACKEND (a\nLaravel app's APP_URL counts) and RSC_HOST_CALL_SECRET. Both or neither.\n\nLaravel: composer require rsc-kit/laravel, then php artisan rsc:install. It\nruns rsc-kit init, which writes ONE vite.config.ts (laravel-vite-plugin is\nmoved aside - the renderer owns the frontend). Source is resources/js (the route tree is resources/js/app).\n\nrpc() is a global the renderer installs in its own process (never imported,\nnever in the browser bundle): one POST to the backend's host-call endpoint\nwith { function, args }, the secret and the visitor's cookie; the answer is\nthe return value as JSON, typed by rpc<T>(). Refusals arrive as their kind\n(422/401/403/redirect), never a 500; sibling calls in one tick are batched\nand each resolves the moment the backend answers it.\nA BAP server bundle carries no database driver, ORM or auth library - the\nbackend owns those.\n\nReach PHP from a server component - rpc() is a global, typed in\n.rsc-kit/rsc-env.d.ts, server render only:\n\n // app/Rsc/Orders.php: public function recent(int $limit): array\n const orders = await rpc<Order[]>('Orders.recent', 5)\n\nThe call runs AS THE VISITOR (their cookie is forwarded; auth()->user() is\nthem). Refuse with attributes: #[Authenticated], #[Can('update', Order::class)],\n#[Middleware('throttle:60,1')]. A ValidationException lands on the form as\nvalidationErrors; Authentication/Authorization exceptions answer 401/403.\n\nGuard a route in Laravel's vocabulary, no route declared in PHP:\n\n // resources/js/app/admin/middleware.ts\n export const middleware = ['auth', 'verified', 'can:update,post']\n\nMake one: php artisan make:rsc-action Orders --method=cancel --auth\n--can=update,Order --middleware=throttle:60,1 --revalidate=orders (--rpc for\nan rpc() class under app/Rsc; no --method = invokable; a slash nests,\nBilling/Invoices). Do NOT hand-write the attributes from memory; the command\nwrites the ones the registry reads, and it writes the map\n(rsc-host-actions.json) too - a running dev server restarts on its own when\nthe map changes, so the export is importable when the command returns.\nServer actions are classes in app/Rsc/Actions, found by reflection in PHP;\n\\`php artisan rsc:action-manifest\\` writes the map and MUST run before\nVite - the dev and build scripts rsc:install wrote do (\\`php artisan\nrsc:action-manifest && vite\\`), so a class written by hand is picked up by\nthe next \\`npm run dev\\` or \\`npm run build\\`, never by Vite alone. The\nbuild writes the \"use server\" stubs from the map - import ordersCancel from\nthe generated actions module in a client component. Rsc::revalidate('orders')\nin the action returns the re-rendered region with the answer.\n\nPer url: if the React tree has it, React renders it, otherwise Laravel does\n- including / : the welcome route in routes/web.php answers nothing while\nresources/js/app/page.tsx exists (the package registers the tree's urls from\nbootstrap/rsc/vite/routes.json after routes/web.php). Do NOT tell the user\nto delete the welcome route to make the page show; do not add Laravel routes\nfor React pages.\n\nphp artisan serve is one worker, which deadlocks the proxy - unless\nPHP_CLI_SERVER_WORKERS=4 in .env AND serve --no-reload (Laravel ignores\nthe variable otherwise). Herd, Valet, FPM, Octane are fine as they are. Production: put the renderer in front (bun\n.output/server/index.mjs with the app's .env), restrict /__rsc/host-call at\nthe web server.\n\nAny other language implements the same endpoint - request { function, args },\nreply { result | validationErrors | unauthenticated | unauthorized | redirect\n| error, revalidate }, answers '__rsc.middleware' with true or a refusal, and\na batch { calls: [...] } as application/x-ndjson - one line per call AS IT\nFINISHES, { index, status, ...reply }, in any order, flushed each time\n(X-Accel-Buffering: no) - or, less good, one JSON { replies: [...] } in\norder (every call then waits for the slowest). Calls issued in the same\nrender tick travel as one batch, so parallel reads are one backend request\nand a fast read still resolves while a slow sibling runs; the renderer falls\nback to single calls for a backend without batches.\n\nFull guides: read_guide({ slug: 'backend-answered-pages' }), read_guide({ slug: 'laravel' }), read_guide({ slug: 'go' }), read_guide({ slug: 'your-own-backend' }).`,\n },\n {\n topic: 'startup',\n summary: 'Once-per-process setup - src/instrumentation.ts is imported before any page and its register() awaited before the first request',\n body: `Setup that belongs to the process - validating env, configuring a shared\npackage, warming a connection - goes in src/instrumentation.ts. Do NOT import\na bootstrap module from pages to get the same effect; it depends on nobody\nforgetting, and the failure is a page throwing \"not configured\" for whoever\nreaches it first.\n\n // src/instrumentation.ts\n import './env' // refuses at import -> server fails at startup\n export async function register() { // optional; the first render waits for it\n await db.connect()\n }\n\nThe generated entry imports this file FIRST, so a package configured here is\nconfigured before any page module evaluates. register() is awaited by every\nentry point (server, dev, prerender, middleware, actions, api routes), once\nper process. On a server it runs at startup and a failure exits the process;\non a Worker it runs at the isolate's first request.\n\nWorker rule: read bindings INSIDE register(), not at the top of the module -\nprocess.env is empty until the first request arrives.\n\nA scaffolded app with env validation already has this file importing ./env.\nBuild machines without production variables: SKIP_ENV_VALIDATION=1.\n\nNever NODE_ENV in a .env: Vite sets it (development under vite, production\nunder vite build) and a .env line overrides it for the build, which then\ncompiles pages against React's dev JSX runtime (jsxDEV) and every route\nfails with React's opaque \"message omitted in production builds\". The build\nrefuses this and names the file:line. A plugin cannot override it (Vite\napplies the .env value after plugins run), so remove the line - other tools\nthat want it keep it in their own .env.\n\nFull guide: read_guide({ slug: 'instrumentation' }).`,\n },\n {\n topic: 'bun',\n summary: 'Running on Bun - Vite on Bun\\'s runtime (bun --bun vite), native deps external (serverExternalPackages), and the gotchas that are Bun\\'s not ours',\n body: `The vite bin has a node shebang: \\`bun run dev\\` alone starts Vite - dev\nserver, build, prerender - under NODE, and an app importing 'bun' or\n'bun:sqlite' fails at first render with \"Cannot find package 'bun'\". Scripts\non a Bun app: dev \"bun --bun vite\", build \"bun --bun vite build\". createTestApp\nruns the project's build script on the runtime the tests use.\n\nNative dependencies (sharp, bcrypt, better-sqlite3, @prisma/client, puppeteer,\n...) are external to the server bundles by default; Nitro traces them into\n.output/server/node_modules with their binaries. Add one:\nrscKit({ serverExternalPackages: ['@acme/native'] }). Same as Next's option.\n\nThe other direction: a dependency with \"use client\" files that does NOT\ndeclare react as a peerDependency (generated wrappers, workspace packages\nwith react under dependencies) would be left external by plugin-rsc and its\ndirective never read - hooks then run on the server. The build detects direct\ndependencies in that state and bundles them, printing\n\"[rsc-kit] bundling <pkg>: it has \"use client\" files but does not declare\nreact as a peer dependency\". Nothing to configure; fix the package's\npeerDependencies when it is yours.\n\nBun's, not the framework's: bun test loads the package .env (use\n--env-file=/dev/null to isolate); Stripe's constructEvent throws on Bun\n(no sync WebCrypto) - use constructEventAsync; Bun's pg puts SQLSTATE in\nerrno where Node's pg uses code.\n\nNever NODE_ENV in a .env (the build refuses it, naming the line). Build\nmachines without secrets: SKIP_ENV_VALIDATION=1.\n\nreflect-metadata (tsyringe, typeorm, inversify - often under\n@simplewebauthn/server): nothing to import; when the graph has it the build\nloads it in a Nitro plugin ahead of the app, so the \"tsyringe requires a\nreflect polyfill\" boot error does not happen in a directory or a binary.\n\nBuild, then start - NEVER run vite build while bun/node .output/server/index.mjs\nis serving from that .output: services load lazily and a failed import of a\nhalf-written chunk is cached by the runtime (ENOENT 500s until restart).\n\nSingle binary (Bun): bun build --compile .output/server/compile.mjs\n--outfile dist/app (the scaffold's \"compile\" script). compile.mjs is written\nby the build and embeds the frozen pages; with serveStatic: 'inline' in the\nNitro plugin the assets (and their .br/.gz) are inside too. Ship dist/app\nalone - a Dockerfile copies nothing else, not .output/public. --bytecode\ncompiles the JS ahead of time (cold boot 275 ms -> 72 ms measured, for an\nimage 81 MB -> 117 MB - on a pod the pull costs more than the boot saves).\nBytecode is CommonJS: a module using import.meta.env / .filename / .resolve\n/ bare import.meta (a dependency, usually) fails it, and Bun names no file\nand EXITS 0 - the binary dies at boot with \"import.meta is only valid\ninside modules\". The build prints the file and line at the end of a bun\nbuild when there is one; --bytecode --format=esm applies regardless. A production\napp ported from Next measured the binary image at 50.12 MiB against the\nNext image's 104.56 MiB, the docker build at 2m45s against 5m13s, and\nLighthouse at 99 mobile / 100 desktop - nothing tuned for the numbers.\n\nNothing is sent raw: a built bun/node server gzips what it answers\n(documents, streams flushed per chunk, payloads, stored pages, api routes)\nfor a request that accepts it, and the build writes .br/.gz beside every\npublic asset, served by Nitro. Nothing to configure; a Worker leaves it to\nthe platform. Off: compress: false on the handler / compressPublicAssets:\nfalse in Nitro config; Cache-Control: no-transform exempts one answer. Do\nNOT add a compression middleware or precompress assets yourself.\n\nFull guide: read_guide({ slug: 'bun' }).`,\n },\n {\n topic: 'openapi',\n summary: 'An OpenAPI document derived from route.ts files - rscKit({ openapi }) - and Scalar\\'s page over it, mounted as a route',\n body: `Do NOT hand-write an OpenAPI spec. rscKit({ openapi: true }) in\nvite.config.ts answers /openapi.json, derived from every route.ts: the\ndirectory is the path ([id] -> {id}), each method export an operation,\nparams/searchParams/body schemas the parameters and request body (Zod 4 and\nArkType describe themselves as JSON Schema; Valibot not yet), a middleware.ts\nabove a route a security requirement + 401/403. Stored at build, no middleware.\n\nDocument-level parts go on the option:\n rscKit({ openapi: { info, servers, security, components: { securitySchemes } } })\nWhat a route says about itself, beside its handler:\n export const openapi = { summary, tags, responses: { 200: {...} }, POST: { summary } }\n export const openapi = false // leave this route out (the reference page, a webhook)\n export const openapi = { DELETE: false } // one method out; HEAD/OPTIONS never documented\nWebhook-heavy app: rscKit({ openapi: { include: 'declared' } }) documents only\nroutes that export openapi, so callbacks need no opt-out line.\nResponse bodies are declared in openapi.responses until a typed helper exists.\n\nThe page: Scalar's own package, one route, nothing shipped by the engine:\n // src/app/reference/route.ts\n import { ApiReference } from '@scalar/nextjs-api-reference'\n export const GET = ApiReference({ url: '/openapi.json' })\n export const openapi = false\n\nPorting a spec file: delete its paths (they are the routes now, and body\nvalidates at runtime), move info/servers/security to the option, move a\nroute's summary/tags/responses to its openapi export.\n\nFull guide: read_guide({ slug: 'openapi' }).`,\n },\n {\n topic: 'env',\n summary: 'Typed environment variables - src/env.ts with @t3-oss/env-core in the app\\'s validation library; refused at startup by name. Never NODE_ENV in .env',\n body: `A scaffolded app has src/env.ts when it said yes to typed environment\nvariables (create-rsc-kit --env, with --validation=zod|valibot|arktype). To\nadd it to an app without one: install @t3-oss/env-core and write the same file.\n\n\\`\\`\\`ts\n// src/env.ts\nimport * as z from 'zod' // or valibot / arktype - any Standard Schema library\nimport { createEnv } from '@t3-oss/env-core'\n\nexport const env = createEnv({\n server: {\n NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),\n DATABASE_URL: z.url(),\n SESSION_SECRET: z.string().min(1),\n },\n clientPrefix: 'PUBLIC_',\n client: { PUBLIC_SITE_URL: z.url() },\n // No bare process.env: a \"use client\" file importing this for a PUBLIC_\n // value has no process, and the spread throws before the first render.\n runtimeEnv: { ...(typeof process === 'undefined' ? {} : process.env), ...import.meta.env },\n emptyStringAsUndefined: true,\n skipValidation: typeof process !== 'undefined' && !!process.env.SKIP_ENV_VALIDATION,\n})\n\\`\\`\\`\n\nRead env.DATABASE_URL, never process.env.DATABASE_URL: the first is typed and\nwas checked at startup (a missing or malformed one fails then, with its name),\nthe second is string | undefined. A server variable never reaches the browser;\na browser-readable one MUST start with PUBLIC_ and is read from import.meta.env\n(Vite; the engine registers PUBLIC_ beside VITE_ as a client prefix, nothing\nto configure), which is why runtimeEnv merges both. Commit .env.example, not .env.\n\nNext: NEXT_PUBLIC_* becomes PUBLIC_*; @t3-oss/env-nextjs becomes\n@t3-oss/env-core with runtimeEnv as above (env-nextjs's experimental__runtimeEnv\nis not needed).`,\n },\n {\n topic: 'live-data',\n summary: 'A value that keeps changing - realtime, live updates: usePolling over a query, or server-sent events (SSE, streaming from a middleware.ts generator) with useEvents - both feed TanStack, SWR or setState',\n body: `Neither is part of query() - a query answers once and is cacheable.\n\nPOLLING - start here when you have no change feed yet. Reuses the query,\ngoes through its Cache-Control (a CDN collapses many tabs into one origin\nread per interval), pauses when the tab is hidden, never overlaps two reads.\n\\`\\`\\`tsx\nimport { usePolling } from '@rsc-kit/core/usePolling'\nconst { data, status, refresh } = usePolling(() => fetchQuery(getSeats), { every: 2_000 })\n\\`\\`\\`\nUNTIL IT SETTLES - a job that ends. until(data) says the last read; onSettled\nfires once on it. The result is the DATA; what to\ndo on settling is the page's choice:\n // a server-rendered list, some jobs still running: re-render through the server\n usePolling(() => fetchQuery(jobStatus, [id]), { every: 2_000, enabled: !isTerminal(job), until: isTerminal, onSettled: () => refresh('page') })\n // the page that owns the job's state machine: the value in hand\n const { data, status } = usePolling(read, { every: 1_500, until: isTerminal, onSettled: (f) => dispatch(f.status) })\nSettled = stopped until refresh() or the inputs change. status: 'reading' |\n'paused' | 'settled' | 'idle'.\n\nSERVER-SENT EVENTS - when something can push. Better per update (bytes only\non change, instant), but holds a connection per open tab (fine on Bun/Node,\na limit on Workers or a small container), is uncacheable, and needs a source\nof change to yield from - a generator that polls the DB itself just moved the\npolling. An ordinary route.ts: beside its pages, runs middleware.ts above it.\n\\`\\`\\`ts\n// src/app/api/orders/[id]/events/route.ts\nimport { events, named } from '@rsc-kit/core/events'\nexport const GET = events(async function* ({ params, signal }) {\n const { id } = await params\n for await (const status of orderStatus(id, { signal })) yield { status }\n // yield named('paid', order, { id: order.id }) names a message / gives an id\n})\n\\`\\`\\`\n\\`\\`\\`tsx\nimport { useEvents } from '@rsc-kit/core/useEvents'\nconst { latest, all, status, close } = useEvents<Status>(\\`/api/orders/\\${id}/events\\`)\n// <Status> is the message type the route yields; a url infers nothing, so\n// without it latest is unknown. Declare the type beside the route, import both sides.\n\\`\\`\\`\nevents() frames JSON, sends a keepalive, sets text/event-stream + no-store,\nends the generator on disconnect (signal). EventSource reconnects itself and\nresumes with Last-Event-ID when you yielded ids.\n\nNO LIBRARY NEEDED. Both hooks ARE state: read data (polling) or latest\n(events) and render it. Neither needs TanStack or SWR.\n const { latest } = useEvents<Status>(url); const current = latest ?? initial // the server value until the first message\nWITH A STORE - when the value already lives somewhere, hand every value on so\nthat stays the truth: a useState, a reducer, or a cache library:\n useEvents<Order>(url, { onMessage: setOrder }) // useState\n useEvents<Order>(url, { onMessage: (m) => dispatch({ type: 'update', m }) }) // reducer\n useEvents<Order>(url, { onMessage: (m) => queryClient.setQueryData(['order', id], m) }) // TanStack\n useEvents<Order>(url, { onMessage: (m) => mutate(['order', id], m, false) }) // SWR\n usePolling(read, { every, onData: setSeats })\nSTABLE CALLBACKS: a callback a timer/subscription/listener calls that must see\nthe latest props is useEffectEvent from React (19.2+), never a ref assigned\neach render, and never in a dependency array. Both hooks are built on it.\n\nERRORS: both hooks expose error as state AND fire onError - a failed poll read\n(the next interval still reads) or a dropped stream (EventSource reconnects\nitself). Use onError for a toast/log; do NOT watch error in a useEffect.\nusePolling's onError gets (error, { failures }) - failed reads in a row, reset\nby a success - so toast on the third, not the first.\nfetchQuery(query, args) types args from the query: fetchQuery(getSeats) for a\nquery that takes nothing, fetchQuery(status, [{ id }]) refused if the query's\ninput has no id.\nDo NOT put a stream on query() or on a server action, and do NOT poll from\ninside an events() generator.\n\nFull guides: read_guide({ slug: 'queries' }) for polling, read_guide({ slug:\n'api-routes' }) for the streaming route.`,\n },\n {\n topic: 'images',\n summary: 'Responsive images with no optimizer - unpic for a CDN, imagetools for files in the repo',\n body: `There is NO image component and NO image server. Do not add next/image or\nwrite an optimizer route. next/image is a srcset-writing component plus a\nresize-on-request process; the first is a library, the second belongs to the\nCDN.\n\nAn image on a CDN (Cloudinary, imgix, Cloudflare Images, Bunny, Vercel,\nNetlify, ...): @unpic/react. Plain component, works in a server component,\nships no javascript, detects the CDN from the url:\n\n\\`\\`\\`tsx\nimport { Image } from '@unpic/react'\n\n<Image src=\"https://res.cloudinary.com/demo/image/upload/sample.jpg\" layout=\"constrained\" width={800} height={600} alt=\"...\" />\n\\`\\`\\`\n\nA file in the repo, a handful of them: vite-imagetools, resized ONCE at build\ntime. Add imagetools() to the vite plugins, then:\n\n\\`\\`\\`tsx\nimport hero from '../hero.png?w=400;800;1200&format=webp&as=srcset'\nimport heroSrc from '../hero.png?w=800&format=webp'\n\n<img srcSet={hero} src={heroSrc} sizes=\"(min-width: 800px) 800px, 100vw\" width={800} height={600} alt=\"...\" />\n\\`\\`\\`\n\nDeclare the query tails in src/images.d.ts, or the build's typecheck stops on\nthe imports (a pattern may hold ONE *, so '*?*' matches nothing):\n\n declare module '*&as=srcset' { const srcset: string; export default srcset }\n declare module '*&format=webp' { const url: string; export default url }\n\nHundreds of files in the repo: that is a CDN's job; move them and use unpic.\nAn icon or a logo: a plain <img>, or inline the svg.\n\nFull guide: read_guide({ slug: 'images' }).`,\n },\n {\n topic: 'scripts',\n summary: 'Third-party scripts - analytics, tag managers - without a Script component',\n body: `Write the script tag. React 19 does what Next's Script component existed for.\n\nAn external script with async, rendered from a server component, is HOISTED\ninto head and DEDUPLICATED by React - the same src in three components is one\ntag. That is afterInteractive:\n\n\\`\\`\\`tsx\n<script async src=\"https://www.clarity.ms/tag/abc123\" />\n\\`\\`\\`\n\nAn inline snippet renders where it is written and runs during parse, before\nhydration - the earlier moment, which is what an analytics snippet wants:\n\n\\`\\`\\`tsx\n<script id=\"ms-clarity\" dangerouslySetInnerHTML={{ __html: '...' }} />\n\\`\\`\\`\n\nPut site-wide scripts in the ROOT LAYOUT, which renders once and is kept\nacross navigations.\n\nThere is no Script component to import. The only case needing one - a script\nthat touches DOM React rendered, or an onLoad callback - is a client component\nwith useEffect that creates the tag. Ten lines of the user's own.`,\n },\n {\n topic: 'testing',\n summary: 'Unit-testing actions, queries and routes; the whole app without a port',\n body: `Almost everything is a function. Any test runner works.\n\n**Actions, queries, api routes: import and call.** \"use server\" is a string in\na test file, so the function is importable. An action built on the action\nclient runs its whole middleware chain when called and RETURNS its failures:\n\n\\`\\`\\`ts\nconst result = await createPost({ title: '' })\nexpect(result.validationErrors).toEqual({ title: ['too short'] })\n\\`\\`\\`\n\nAn api route takes a Request and the context the engine gives it - params is a\nPROMISE:\n\n\\`\\`\\`ts\nconst res = await GET(new Request('https://app.test/api/x'), { params: Promise.resolve({ id: '1' }) })\n\\`\\`\\`\n\n**Anything reading cookies() or headers():** open the request scope yourself.\n\n\\`\\`\\`ts\nimport { withRequest } from '@rsc-kit/core/request'\nawait withRequest(new Request('https://app.test/', { headers: { Cookie: 'session=abc' } }), currentUser)\n\\`\\`\\`\n\n**The whole app as Request -> Response, no port:**\n\n\\`\\`\\`ts\nimport { createTestApp } from '@rsc-kit/core/testing'\nconst app = await createTestApp()\nconst res = await app.fetch('/admin', { redirect: 'manual' }) // real router, real middleware\n\\`\\`\\`\n\nIt builds when the source is newer than the last build - the first run pays,\nthe rest do not. This is where a guard that never ran or a 404 that came back\n200 shows up.\n\n**What still needs a browser:** a server action called OVER THE WIRE (the id is\nReact's and private), hydration, navigation. Playwright against vite preview.\nThat limit is narrower than Next's: the action's logic is a unit test here.\n\n**What to write when you add something.** Before running check, not after:\n\n- A guarded route (middleware.ts, or a page reading the session): a stranger\n is turned away, and someone signed in gets 200.\n \\`\\`\\`ts\n expect((await app.fetch('/admin', { redirect: 'manual' })).status).toBe(302)\n expect((await app.fetch('/admin', { headers: { Cookie: 'session=ada' } })).status).toBe(200)\n \\`\\`\\`\n- An action: its refusal, by calling it. Bad input answers validationErrors;\n a stranger answers serverError (or throws ServerAuthenticationError if you\n built it without the client).\n \\`\\`\\`ts\n expect((await createPost({ title: '' })).validationErrors).toBeDefined()\n \\`\\`\\`\n- An action that takes an id: someone else's id is refused. This is the IDOR\n test and the one most often missing.\n- A query: the shape of its answer, and what a filter changes.\n- An api route: status, content-type, and the 4xx it answers to a bad body.\n- A page that should stay static: assert on the build report - no test, a CI\n check that build-report.json still says frozen for it.\n\nDo NOT start a dev server, spawn a process or pick a port in a test. Do NOT\nadd a second runner. The one in tests/ goes through the real build already.`,\n },\n]\n\n/** Every topic, with one line each — what a caller reads before choosing. */\nexport function listTopics(): string {\n return [\n 'Topics. Ask for one with how_to({ topic }).',\n '',\n ...RECIPES.map((r) => `${r.topic.padEnd(16)} ${r.summary}`),\n ].join('\\n')\n}\n\n/** One recipe, or the list plus a nudge when the topic is not one. */\nexport function howTo(topic: string): string {\n const wanted = topic.trim().toLowerCase().replace(/[\\s_]+/g, '-')\n const found =\n RECIPES.find((r) => r.topic === wanted) ??\n // A near miss is common and worth answering rather than refusing: someone\n // asks for \"form\" or \"queries\" and means the obvious thing.\n RECIPES.find((r) => r.topic.startsWith(wanted) || wanted.startsWith(r.topic)) ??\n // The summary is prose, so a multi-word ask is matched with its spaces back.\n RECIPES.find((r) => r.summary.toLowerCase().replace(/[-\\s]+/g, ' ').includes(wanted.replace(/-/g, ' ')))\n\n if (!found) return `No topic \"${topic}\".\\n\\n${listTopics()}`\n\n return `# ${found.topic} — ${found.summary}\\n\\n${found.body}`\n}\n\n/** For tests, so a recipe cannot be added without being reachable. */\nexport const TOPICS = RECIPES.map((r) => r.topic)\n"]}
|
|
1
|
+
{"version":3,"file":"recipes.js","sourceRoot":"","sources":["../src/recipes.ts"],"names":[],"mappings":"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","sourcesContent":["// How to build the things this framework has, in the shape that works.\n//\n// The other half of this server, and the more useful one. Introspection answers\n// \"what did my build do\"; this answers \"how do I do X here\", which is the\n// question an agent actually has — and the one it otherwise answers from Next\n// and React habits that produce code which looks right and is not.\n//\n// Long-form on purpose. AGENTS.md has to be short enough to sit in context for\n// every turn, so it can only say the rule. These are fetched when the topic\n// comes up, so they can afford the working example and the caveat under it.\n//\n// Every snippet here is the recommended spelling from the guides, not a\n// paraphrase. When a guide changes, this changes with it — a recipe that has\n// drifted is worse than no recipe, because it is followed with confidence.\n\nexport interface Recipe {\n topic: string\n summary: string\n body: string\n}\n\nconst RECIPES: Recipe[] = [\n {\n topic: 'forms',\n summary: 'Submitting to a server action, with pending state and field errors. Uncontrolled by default - no useState per field',\n body: `THE RULE: forms are UNCONTROLLED. Inputs keep their value in the DOM,\nan initial value is defaultValue, the action reads FormData. Do NOT write\nuseState + value/onChange per input, and do NOT reach for TanStack Form.\nControl ONE field only when the UI must react as the user types (a character\ncount, a live preview, a dependent select) - bind it with useField, which\nscopes the re-render to that field. Everything else stays uncontrolled.\n\nUse <Form>. It takes the server action itself, not a url.\n\n\\`\\`\\`tsx\n'use client'\nimport Form from '@rsc-kit/core/Form'\nimport { createPost } from '../actions'\n\nexport function NewPost() {\n return (\n <Form action={createPost} schema={schema}>\n {({ pending, errors }) => (\n <>\n <input name=\"title\" />\n {errors.title?.[0] && <p>{errors.title[0]}</p>}\n <button disabled={pending}>Save</button>\n </>\n )}\n </Form>\n )\n}\n\\`\\`\\`\n\nPassing \\`schema\\` validates in the browser BEFORE the action is called, so a\nmistake costs no round trip. It is a courtesy, never a control: the action is a\npublic endpoint reachable without your form, so the server must check too.\n\nA schema on the server (\\`client.input(schema)\\`) does NOT give you client-side\nvalidation. Pass it to the form as well — the same schema is fine.\n\nValues are uncontrolled, so an initial one is React's own \\`defaultValue\\`. A\nrefused submit keeps what was typed, because the DOM kept it.\n\nA repeated name is an array. With one selected it is a string, which no\nz.array() accepts - so for anything that is a list by nature end the name in\n\\`[]\\` and it is always an array, brackets dropped from the key:\n\n\\`\\`\\`tsx\n<input type=\"checkbox\" name=\"tags[]\" value=\"react\" /> // -> { tags: ['react'] }\n\\`\\`\\`\n\nNames that describe a shape build it: \\`address.city\\` nests, and\n\\`items[0].name\\` (or \\`items[0][name]\\`) makes an array of objects. That is\nthe shape the schema was written against, and errors come back keyed the same\nway because Standard Schema issue paths join with dots too.\n\nFor a control with no native element behind it - a rich editor, a Radix select -\nor a value read as it is typed, bind it with \\`field()\\`. It is the same four\nprops react-hook-form's Controller gives:\n\n\\`\\`\\`tsx\n<Form action={save} defaultValues={{ body: '' }}>\n {({ field }) => (\n <>\n <Editor {...field('body')} />\n <span>{field('body').value.length}/100</span>\n </>\n )}\n</Form>\n\\`\\`\\`\n\nonChange takes a DOM event OR a bare value, so native inputs and Radix\ncomponents both work. A bound field is still an ordinary named input, so it\narrives in FormData with the rest - nothing merges.\n\n\\`fieldState(name)\\` is the other half: { touched, invalid, errors }. Two\nobjects rather than one because touched and invalid are not DOM attributes and\nspreading them would warn on every field.\n\n\\`\\`\\`tsx\nconst title = fieldState('title')\n<Field data-invalid={title.invalid}>\n <Input {...field('title')} aria-invalid={title.invalid} />\n <FieldError errors={title.errors.map((message) => ({ message }))} />\n</Field>\n\\`\\`\\`\n\nA field is checked when it is LEFT, not as it is typed, and it works on\nuncontrolled fields too - the form listens for focusout rather than each field\nlistening for blur.\n\nThere is no per-field render prop component here, and that is deliberate.\nTanStack Form is controlled-first, so it needs one - without per-field\nsubscriptions a keystroke re-renders every field. react-hook-form is\nuncontrolled-first like this, and its Controller scopes the re-render of a\ncontrolled field to itself.\n\nfield() is a function call instead, which keeps the markup flat and means a\nbound field re-renders the form rather than only itself. Right for the one or\ntwo controlled fields a form usually has.\n\nWhen it is not, put the field in its own component and use \\`useField\\` there -\nit re-renders that component and nothing else, which is what Controller achieves\nwith a render prop:\n\n\\`\\`\\`tsx\nfunction Title() {\n const { invalid, errors, ...bound } = useField('title')\n\n return <Input {...bound} aria-invalid={invalid} />\n}\n\\`\\`\\`\n\n\\`useFormValues()\\` reads every bound value from anywhere inside the form - a\npreview, a summary. Only BOUND values: an uncontrolled input's value is the\nDOM's and nothing can know it changed.\n\nBoth read a context, so they work below <Form>. For something that is NOT a\ndescendant - a top bar, a sidebar preview - create the store above both and\nhand it in:\n\n\\`\\`\\`tsx\nconst store = useFormStore({ title: '' })\n\n<TopBar store={store} /> // outside the form\n<Form action={save} store={store}>…</Form>\n\\`\\`\\`\n\nuseFormStore is the values and nothing else - no submit, no errors. Creating it\ndoes not subscribe to it, so the holder does not re-render per keystroke and\ntake the subtree with it. useField(name, store) and useFormValues(store) take\none explicitly; without one they read the context.\n\nA submit from outside the form is html, not a second api:\n\n\\`\\`\\`tsx\n<Form id=\"bug-report\" action={reportBug}>…</Form>\n<Button type=\"submit\" form=\"bug-report\">Submit</Button>\n\\`\\`\\`\n\nThere is no useForm hook. <Form> is the whole surface.\n\nFields are real \\`name\\` attributes rather than controlled state, so the form\nreads a native FormData and any component rendering a real control works.\n\nIt works before hydration. The action is on the form element as well as in the\nsubmit handler, so the markup is submittable on its own - the handler calls\npreventDefault() first and React does not run a form action for a cancelled\nsubmit, so exactly one path runs.\n\n**shadcn/ui works as-is.** Input, Textarea, Button and Label are styled native\nelements, so \\`name\\` does what it always does. Select, Checkbox, Switch and\nRadioGroup are Radix underneath and render a hidden native control whenever\ngiven a \\`name\\` - omit it and they are invisible to the form, which is the\nonly thing to remember.\n\nDo NOT use shadcn's own Form/FormField/FormControl with this. Those wrap\nreact-hook-form, a different system for the same job. One or the other.`,\n },\n {\n topic: 'prefetch',\n summary: 'Making a navigation feel instant',\n body: `\\`<Link>\\` fetches its payload as it comes into view, on EVERY device\n(desktop too), when the browser is idle, once per link, as Next does - the\nfirst 12 links in view per page (a payload is the whole page, 30 KB for a\nproduct with its related products; the rest wait for a touch or a settled\nhover) - the bytes only, held 30 s (per-visitor page) or 5 min (a page the build made,\nmarked public), and the eager <img>s the payload names are preloaded at low\npriority as it lands (the first 6 a page; not loading=\"lazy\"; nothing under\nSave-Data), so a product picture is decoded before the tap. Do NOT write an\nimage-prefetch route or an effect per card in the app. It is decoded - which\nloads the page's client chunks - on intent: a pointer settled 100 ms on the link, a mousedown, a touchstart,\neach 60-300 ms before the click. A click then finds a decoded tree and\nnothing on the network is between the click and the page: the SPA feel,\non a slow network too. A link to a guarded page (Sign in -> /agent ->\n/login) prefetches the redirect's destination too, so the tap asks for\nnothing. Save-Data turns the viewport prefetch off. To MEASURE a navigation\n(the number \"slow on my phone\" needs): every navigation leaves User Timing\nmarks rsc-kit:navigate:start / :decoded / :applied and a measure\nrsc-kit:navigate (click to the commit) - in the Performance panel, or\nperformance.getEntriesByName('rsc-kit:navigate') in a console; a gap\nbefore :decoded is network or chunks (the link was not on screen, or\nprefetch={false}), after :applied is the render. A tap BEFORE the runtime has\nhydrated is held by the bootstrap script and navigated to once the router\nis wired (a document load after 4 s if the runtime never comes) - so a slow\nphone's first tap is not a full reload. A form posting a server action\nsubmitted in that window is held and submitted again after hydration, so\n\"Add to cart\" tapped early is an action, not a full-page POST. Usually there is nothing to do; do\nNOT add a viewport observer, touch handler or pre-hydration click shim in\nthe app. A tag manager on the main thread (Facebook Pixel, Clarity) is the\nusual reason hydration is late: load those after the page is interactive.\nA <ViewTransition> around the page is the usual reason a navigation is\nslow on an iPhone (every iOS browser is WebKit): WebKit snapshots the\noutgoing page at its FULL height, a 12,000 px landing page is 44 megapixels\nat 3x, ~600-900 ms frozen per navigation and enough memory for iOS to\ndiscard the tab (which returns as a reload). A navigation here is an\ninstant swap, like Next - do NOT add a page fade in a port; a port that\nhas one should remove it. The transition worth having is a shared element\n(<ViewTransition name=...> on one image). If a page fade is wanted anyway,\n<PageTransition className=...> from @rsc-kit/core/PageTransition is the\none that skips WebKit (and reduced motion), and ships its own CSS - a\n120 ms cross-fade with pointer-events off on the overlay; nothing to add to\nthe stylesheet. Props: duration (ms, 0 = instant swap), webkit=\"run\" for a\none-screen page that measured fine. See read_guide view-transitions.\n\n\\`\\`\\`tsx\nimport Link from '@rsc-kit/core/Link'\n\n<Link href=\"/orders\">Orders</Link>\n<Link href=\"/orders\" prefetch={false}>Orders</Link> // opt out\n<Link href=\"/orders\" cacheFor={30_000}>Orders</Link> // hold the payload longer\n\\`\\`\\`\n\n\\`href\\` is typed to the routes the build found, so a link to a page that no\nlonger exists stops compiling. Cast with \\`as Route\\` only when the destination\nis genuinely computed.\n\nTo prefetch from code — a row about to be clicked, a wizard's next step:\n\n\\`\\`\\`ts\nimport { prefetch } from '@rsc-kit/core/router'\n\nprefetch('/orders/42')\nprefetch('/orders/42', undefined, { intent: true }) // and decode it now: the chunks too\n\\`\\`\\`\n\nWhat is prefetched is the RSC payload, not the html, so it is small and it warms\nthe same cache the navigation will read. Never the page on screen, and never a\npage still held behind it (the page just left): a navigation to that reveals\nit. After an action that revalidated, and after refresh(), every prefetched\npayload and every held page is dropped - they are from before the write - so\nvisit() to a list after creating a row fetches the list with the row in it.\nWhat an action re-rendered goes under the page it was invoked on: a tap that\nleft the page while the action was in flight is answered by fetching the page\nnow showing again, whole, never by putting the old page under the new url.`,\n },\n {\n topic: 'validation',\n summary: 'Checking input — forms, actions, urls and request bodies',\n body: `One contract everywhere: any Standard Schema (Zod, Valibot, ArkType).\n\n**Actions** validate on arrival and RETURN their failures, because React strips\na thrown message in production:\n\n\\`\\`\\`ts\nexport const createPost = client.input(schema).handler(async ({ input, ctx }) => …)\n\\`\\`\\`\n\n**Urls** validate by exporting a schema beside the page or route:\n\n\\`\\`\\`ts\nexport const params = z.object({ slug: z.string().min(1) })\nexport const searchParams = z.object({ page: z.coerce.number().int().min(1).default(1) })\n\\`\\`\\`\n\nValues arrive parsed and typed — \\`?page=3\\` is the number 3, a missing one is\nthe default. Never hand-parse \\`Number(searchParams.get('page'))\\`.\n\nThe same schema types every LINK to that page. Write search params as an\nobject, never as a string:\n\n\\`\\`\\`tsx\n<Link href=\"/search\" search={{ q: 'shoes', page: 2 }}>…</Link> // typed by the page's schema\nvisit(href('/search', { q: 'shoes' })) // same check, as a string\n\\`\\`\\`\n\nA key the page never reads, or a number written as text, does not compile;\na key the page requires is required on the link. A page with no schema takes\nany scalars. Do NOT build \\`?q=\\${q}\\` by hand when the page has a schema.\n\n**Api route bodies** the same way:\n\n\\`\\`\\`ts\nexport const body = z.object({ title: z.string().min(1) })\n\nexport async function POST(request: Request, { body }) {\n const { title } = await body\n}\n\\`\\`\\`\n\nThe failures answer differently on purpose:\n\n bad params 404 — the url does not describe a page\n bad searchParams the error boundary (400 for an api route)\n bad body 422, the status an action already uses\n\nA bad query is deliberately NOT a 404, or one bad link makes a real page look\ndeleted.`,\n },\n {\n topic: 'action-client',\n summary: 'Middleware for server actions, so a check cannot be forgotten',\n body: `\\`\\`\\`ts title=\"src/server/client.ts\"\n'use server'\nimport { createActionClient } from '@rsc-kit/core/action'\n\nexport const client = createActionClient({ onError: report })\n .use(async ({ next }) => {\n const user = await currentUser()\n\n if (!user) throw new ServerAuthenticationError()\n\n return next({ ctx: { user } })\n })\n\\`\\`\\`\n\n\\`\\`\\`ts title=\"src/server/posts.ts\"\n'use server'\nimport { client } from './client'\n\nexport const createPost = client.input(schema).handler(async ({ input, ctx }) => …)\nexport const getPosts = client.query(async ({ ctx }) => …)\n\\`\\`\\`\n\n\\`.handler()\\` is a mutation (POST). \\`.query()\\` is a read (GET). Both run the\nchain, so \\`ctx.user\\` is typed and non-null inside them.\n\nFor a failure the schema cannot know - an account not found, a slug taken -\nthe handler is given \\`fieldErrors\\`, typed to its own input so a field the\nschema does not have is a compile error:\n\n\\`\\`\\`ts\n.handler(async ({ input, fieldErrors }) => {\n if (!account) return fieldErrors({ email: 'Account not found' })\n})\n\\`\\`\\`\n\nWRITE return fieldErrors(...). It throws either way, but TypeScript cannot see\na never-return through a destructured argument, so without the return the\nvalue you checked stays possibly-undefined on the next line. It lands in\nvalidationErrors on that field, the same place a schema refusal does. This is\nnext-safe-action's returnValidationErrors with no schema argument and no\n_errors nesting.\n\nA plain \"use server\" function with no action client imports the same thing,\nuntyped, from '@rsc-kit/core/action' - the engine converts the throw into the\nreturned { validationErrors } on the way out. Same rule: return fieldErrors(...).\n\nThe point is not convenience. An action cannot be added without the check,\nbecause there is no other constructor to reach for.\n\nStack clients for a narrower rule:\n\n\\`\\`\\`ts\nexport const admin = client.use(async ({ ctx, next }) => {\n if (!ctx.user.isAdmin) throw new ServerAuthorizationError()\n return next({ ctx })\n})\n\\`\\`\\`\n\nRoute \\`middleware.ts\\` does NOT run for actions — an action renders no route.\nThat is why the check goes here.\n\nAn action body - arguments and any uploaded files, read whole - is capped at\n8 MB; over it the answer is 413 before a byte is kept. rscKit({ maxActionBody })\nraises it. For large files, mint a pre-signed url and upload straight to storage.`,\n },\n {\n topic: 'data',\n summary: 'Loading data, streaming it, and when the browser needs to refetch',\n body: `**In a server component, just await it.** No loader, no getServerSideProps.\n\n\\`\\`\\`tsx\nexport default async function Page() {\n const posts = await db.posts.all()\n}\n\\`\\`\\`\n\n**Better: do not await.** Pass the promise down and let a client component\nresolve it — the shell paints at once and the rows stream into the same\nresponse, with no request from the browser:\n\n\\`\\`\\`tsx\nexport default function Page() {\n const posts = getPosts() // not awaited\n\n return (\n <Suspense fallback={<Skeleton />}>\n <List posts={posts} /> {/* 'use client': use(posts) */}\n </Suspense>\n )\n}\n\\`\\`\\`\n\nReach for this first. It is the thing RSC is for.\n\n**When the BROWSER decides to refetch** — a filter, another page, a refresh —\ncall fetchQuery. It is a plain async function that returns the typed answer;\nNO library is needed:\n\n\\`\\`\\`tsx\nconst [listings, setListings] = useState(initial) // the server-rendered value\nconst [pending, start] = useTransition()\nconst show = (kind) => start(async () => setListings(await fetchQuery(getListings, [kind])))\n\\`\\`\\`\nWorks in an onClick, onSubmit, useEffect - anywhere in the browser.\n\n**When you want CACHING** (stale-while-revalidate, dedupe, offline), hand the\nsame call to the library that holds the answer; this package ships none:\n\\`\\`\\`tsx\nuseQuery({ queryKey: ['posts', kind], queryFn: () => fetchQuery(getPosts, [kind]) })\nuseSWR(['posts', kind], () => fetchQuery(getPosts, [kind]))\n\\`\\`\\`\nfetchQuery sends the read as a GET and goes to the server every time, which\nis what a fetcher needs. It will never cache, dedupe or batch: a client\ncache is the library's job, and a batch would lose the per-read cache key a\nGET has. The ladder, most reads stopping on the first rung:\n 1. the value now: fetchQuery + setState\n 2. survive a reload / let a CDN serve it: query(fn, { cache: 'public', maxAge })\n - HTTP caching, no code in the page\n 3. staleness, background refresh, optimistic updates, shared across\n components: TanStack or SWR with fetchQuery as the fetcher\nDo not add a cache on top of it, and do not install TanStack for a single\nbutton that reads once.\n\nKeep the arrow: TanStack calls a bare \\`queryFn\\` with its own context, and a\nserver function serialises whatever it is handed.\n\nA value that keeps CHANGING while someone watches - polling, SSE, realtime -\nis how_to live-data, not this.`,\n },\n {\n topic: 'suspense',\n summary: 'Where boundaries go, and why the build cares',\n body: `A boundary is what lets a page be stored with a hole in it rather than not\nstored at all.\n\n\\`\\`\\`tsx\n<Suspense fallback={<Skeleton />}>\n <Slow />\n</Suspense>\n\\`\\`\\`\n\nOr a \\`loading.tsx\\` beside the page, which is the same thing for the whole\nroute.\n\nThe build renders every page. Whatever has not resolved when the budget expires\nbecomes the hole; everything above it is stored and served instantly. So a page\nwith no boundary above its slow part cannot be stored at all — the build says\nso:\n\n ƒ /orders\n blocks before anything can paint. Add a loading.tsx beside it, or put a\n <Suspense> above the waiting, and it has a skeleton to store.\n\nA boundary does NOT fix a frozen \\`Date.now()\\`. Prerendering renders straight\nthrough a component that never awaits, so the value is captured exactly as\nbefore. A boundary becomes a hole only when something inside it waits.\n\nredirect() and notFound() inside a boundary still work: the shell has gone\nout, so the redirect travels in the row's error digest and the browser\nperforms it as a navigation, layouts kept. An error.tsx on the route never\nsees it - a redirect is the page's answer, not a failure - and the same holds\nfor a component under its own <Suspense> and for a parallel route slot. Only\nan authorization check should NOT be there: the layouts above already\nrendered. See the redirects guide.`,\n },\n {\n topic: 'offline',\n summary: 'Service worker, and what it does and does not cache',\n body: `\\`\\`\\`ts title=\"vite.config.ts\"\nrscKit({ offline: true })\n\\`\\`\\`\n\nThe build writes a service worker that precaches the client bundle and caches\npages at runtime — a document fetch warms its payload, a payload fetch warms its\ndocument, so a page reached by a link still works when reloaded offline.\n\nPages the build stored whole are served from the cache FIRST, because they\ncannot change until a deploy and a deploy sweeps the cache. Everything else is\nnetwork-first with the cache as fallback.\n\nNothing marked \\`no-store\\` is ever kept — which is how a guarded page and a\nsession-reading query stay out of a cache that has no notion of who asked.\n\nIn a component:\n\n\\`\\`\\`tsx\nimport { useOnline } from '@rsc-kit/core/useOnline'\n\nconst online = useOnline()\n\\`\\`\\`\n\nThere is no push and no background sync. Push needs a subscription endpoint and\na sender; background sync needs idempotent replay. Both are the app's decisions.\n\nEvery document carries a Link header naming its stylesheet, client entry\nand preloaded fonts (stored documents included); Cloudflare sends it as 103\nEarly Hints. Nothing to configure; do NOT add rel=preload headers or a\n_headers file for these in the app.\n\nAcross a deploy: the worker serves the previous build's document first, so a\nreturning visitor's first navigation reaches the new server from the old\nclient. Handled: every document carries its build (<meta name=\"rsc-kit:build\">),\nthe client sends it as X-RSC-Version, the server answers 409 to another\nbuild and the client loads the document; once the worker has announced a\nnew build (useAppUpdate), the next navigation is a document load; a payload\nnaming a client reference the page lacks reloads like a missing chunk. Do\nNOT add prefetch={false} or reload logic in the app for \"client reference\nnot found\" - it is the package's, and fixed.`,\n },\n {\n topic: 'pwa',\n summary: 'Making the app installable',\n body: `A manifest file beside the routes:\n\n\\`\\`\\`ts title=\"src/app/manifest.ts\"\nimport type { WebManifest } from '@rsc-kit/core/manifest-file'\n\nexport default {\n name: 'Orders',\n shortName: 'Orders',\n themeColor: '#0b0b0c',\n backgroundColor: '#ffffff',\n} satisfies WebManifest\n\\`\\`\\`\n\nRead at build time, so it must be an object literal — not computed, not\nimported from elsewhere.\n\n**Icons need no listing.** Put them in \\`src/app/\\` and the build finds them:\n\n favicon.ico served at /favicon.ico\n icon-192.png <link rel=\"icon\">, and the manifest's icons\n icon-512.png\n apple-icon.png <link rel=\"apple-touch-icon\">\n opengraph-image.png <meta property=\"og:image\">\n twitter-image.png <meta name=\"twitter:image\">\n\nSizes are read from the filename. The build says whether it worked:\n\n [rsc-kit] manifest: Orders is installable\n [rsc-kit] manifest: no icons, so no browser will offer to install this.\n\nThere is no layout to edit — React hoists the tags into <head>.\n\nPair it with \\`offline: true\\`. They are separate options because they are\nseparate decisions.\n\n**Push and background sync** need listeners the generated worker does not have,\nso it imports yours from \\`src/app/sw.js\\` — plain javascript, evaluated by the\nbrowser with no build step in front of it:\n\n\\`\\`\\`js\nself.addEventListener('push', (event) => {\n const payload = event.data ? event.data.json() : {}\n\n event.waitUntil(self.registration.showNotification(payload.title, { body: payload.body }))\n})\n\\`\\`\\`\n\nThe rest is the web api and \\`web-push\\`, not this package: VAPID keys, a\nsubscribe call behind a button, the subscription stored by a server action\nagainst a USER rather than a session, and a sender that deletes an endpoint on\n404 or 410 rather than retrying a dead one forever.\n\nFor background sync, make the endpoint idempotent. The browser decides when a\nsync runs and may run it more than once — a request that reached the server\nwhose response did not arrive is retried, and if that posts a message twice the\nperson sent it twice.`,\n },\n {\n topic: 'no-javascript',\n summary: 'The default is none; \"use client\" is how a page asks for it',\n body: `There is NO JavaScript on a stored page until something on it needs some.\nA route that freezes whole, renders none of the app's client components, and\nhas no server action in its tree ships nothing - no React, no router. The\nbuild says so:\n\n ○ /about no js\n no client components, so ships no javascript; stylesheet inlined\n\n\"use client\" IS the opt-in. Put a client component on the page - a counter, a\n<Link>, an update prompt - and it has the runtime, because there is now\nsomething for the runtime to do. There is NO switch in either direction:\nnothing can need the runtime without a client component or an action in the\ntree, and a page that must stay this way is an assertion on build-report.json\n(its clientJs is null), not a setting.\n\nThe check reads the rendered tree, so a <Link> in a shared layout counts -\npages under a layout with a nav keep the runtime; a route group with its own\nplain layout drops it. Navigation into such a page from a Link elsewhere\nstill works: its flight payload is written with the wrappers.\n\nA page without the runtime also gets its stylesheet inlined when small\n(rscKit({ inlineStylesheets }) to change), and still registers the service\nworker with one inlined line.\n\nDo NOT restructure an app to chase this, and do NOT look for export const\nclientJs - it does not exist. The size column says what each route costs; a\npage that is 82 kB because of one <Link> is fine.`,\n },\n {\n topic: 'api-routes',\n summary: 'HTTP endpoints beside the pages',\n body: `\\`src/app/**/route.ts\\`, one export per method:\n\n\\`\\`\\`ts title=\"src/app/api/posts/[id]/route.ts\"\nimport type { RouteContext } from '@rsc-kit/core/route-schema'\n\nexport const params = z.object({ id: z.coerce.number().int() })\nexport const body = z.object({ title: z.string().min(1) })\n\nexport async function GET(request: Request, { params }: RouteContext<typeof params>) {\n const { id } = await params\n\n return Response.json(await findPost(id))\n}\n\nexport async function POST(request: Request, { params, body }: RouteContext<typeof params, never, typeof body>) {\n const { title } = await body\n\n return Response.json(await createPost(title), { status: 201 })\n}\n\\`\\`\\`\n\nTYPE THE CONTEXT: RouteContext<'/api/items/[id]'> types params from the\nroute's segments (the pattern is checked against the routes the build found);\nRouteContext<typeof params> from a schema. Every field is a PROMISE - a sync\nparams.id is a compile error, never a route that 404s. Do NOT hand-write\n{ params: { id: string } }.\n\nA real \\`Request\\` in, a real \\`Response\\` out. \\`params\\`, \\`searchParams\\` and\n\\`body\\` are awaited, the same way a page's props are.\n\nFetch one through \\`apiUrl\\` and the path is checked against the routes the\nbuild found:\n\n import { apiUrl } from '@rsc-kit/core/routes'\n await fetch(apiUrl('/api/posts/' + id))\n\nA route.ts is a Route too (type Route from '@rsc-kit/core/routes' covers pages\nAND route.ts files, as Next's does): Link, visit and redirect accept it. The\nclient treats a link to a route as an anchor - never prefetched, a full\nnavigation, not a payload fetch. ApiRoute is the narrower union for apiUrl:\napiUrl refuses a page, because fetching one gets html. It checks the PATH, not\nthe response type - for types across the boundary use a server action or a\nquery, where the return type is the function's because it is the same function.\n\nThey run their directory's \\`middleware.ts\\`, so an endpoint under a guarded\npath is guarded.\n\nA \\`GET\\` that reads nothing from the request is answered from disk. Awaiting\n\\`searchParams\\` says the answer depends on the query; never touching it means\nthe stored answer is served for any query at all. NEVER read the query with\nnew URL(request.url).searchParams (the Next way): the build cannot see that\nread, and reading request.url at all makes the route dynamic (the table says\n\"reads the request - url\"). A webhook verification handshake (hub.mode,\nhub.challenge) reads the awaited searchParams. A GET that answers 4xx/5xx to\nthe build is never stored either (the table says \"answered 403 to the build\").\n\nredirect() thrown from a handler is the route's answer: a real 3xx Location\nfor whoever asked (a signed-url export, a moved endpoint); notFound() is its\n404. A guard's redirect above a route.ts is a refusal: a browser that\nnavigated there gets the Location, code that fetched it gets 401 +\nX-RSC-Redirect (fetch would follow a Location and hand back the login page).\n\nExporting a \\`body\\` schema consumes the stream, so \\`request.json()\\` inside the\nhandler will find it already read. Use the parsed value.\n\nA GET that reads nothing is stored - and two more things keep one per request:\na Set-Cookie on its Response (an answer for one visitor; classified dynamic,\nwhatever it read), and a body it froze from Date.now() or Math.random() is\nstored WITH a warning on the route's line. Read the request (await\nconnection()) to run it on demand.`,\n },\n {\n topic: 'authorization',\n summary: 'Guarding pages, actions, api routes and queries',\n body: `Each entry point defends itself. There is no single place that covers all of\nthem, and believing otherwise is how a hole is left.\n\n**A page or an api route**: \\`middleware.ts\\` in its directory guards everything\nat or below it.\n\n\\`\\`\\`ts title=\"src/app/admin/middleware.ts\"\nimport { redirect } from '@rsc-kit/core/redirect'\n\nexport default async function guard() {\n if (!(await currentUser())) redirect('/login')\n}\n\\`\\`\\`\n\nSeveral checks in one directory: export default [signedIn, verified, admin] -\nrun in order, stopping at the first refusal; reuse a check by importing it\nfrom one place. Directories still compose outermost first.\n\n**An action or a query**: middleware does NOT run — they render no route. Build\nthem from an action client so the check cannot be forgotten. See the\n\\`action-client\\` topic.\n\n**Authorise on identity, not arguments.** \\`deletePost(id)\\` that trusts the id\nis the whole of an IDOR: the caller chooses the id, so check the row belongs to\n\\`ctx.user\\`.\n\nA guarded page can still be frozen at build time — the guard is a serving\ndecision, not a build one. Its response is marked private so no cache keeps it.`,\n },\n {\n topic: 'dynamic',\n summary: 'Why a page is not static, and how to choose',\n body: `A page is stored at build time unless it reads the request. Reading it is what\nopts out, and the accessors are async. usePathname() in a client component on\na route that lists no urls ([id] with no generateStaticParams) answers \"\" in\nthat route's shell - one shell serves every url, so no link is active and a\nbreadcrumb is empty until hydration, when the hook moves to the browser's url\nby itself; the boot payload agrees with the shell, so nothing mismatches.\nNothing to wrap, nothing to do.\n\n\\`\\`\\`ts\nimport { cookies, headers, searchParams, connection } from '@rsc-kit/core/request'\n\nconst theme = (await cookies()).get('theme')?.value // { name, value } | undefined, as in Next\nawait connection() // \"render this per visitor\", said deliberately\n\\`\\`\\`\n\nA page's \\`params\\` and \\`searchParams\\` props are promises for the same reason.\n\nThe build says which call did it, per route:\n\n ◐ /locale 85 kB\n cookies(), headers() stream per request; the rest is stored\n\nThat is usually correct — a page whose content depends on who is asking cannot\nbe one stored file. Change it only when the read was accidental.\n\nFor a parameterised route, \\`generateStaticParams\\` turns one shell into a page\nper url:\n\n\\`\\`\\`ts\nexport async function generateStaticParams() {\n return (await db.posts.all()).map((p) => ({ slug: p.slug }))\n}\n\\`\\`\\`\n\nA value that must differ per visitor but needs no server — a clock,\nlocalStorage, a map — belongs in the browser only:\n\n\\`\\`\\`tsx\n'use client'\nimport { browser } from 'react-dom'\n\nfunction Clock() {\n use(browser('the time is the visitor\\\\'s, not the build machine\\\\'s'))\n}\n\\`\\`\\`\n\nIt needs a Suspense boundary, and the page stays frozen.\n\nThere is NO app-wide switch to turn prerendering off (no rscKit({ prerender })\nand no export const dynamic). A page that must render per request says\nawait connection() in the page; the build names any page it could not render\nat build time, and that is the page to mark. \"All of it per request\" is\nawait connection() in the root layout with a root loading.tsx - every page a\nstored fallback with the rest streamed, five times slower on a page that could\nhave been stored (bench/ in the repo). Do not reach for it.`,\n },\n {\n topic: 'metadata',\n summary: 'Titles, share cards, and the one setting production needs',\n body: `\\`\\`\\`tsx\nexport const metadata: Metadata = {\n title: 'Orders',\n openGraph: { title: 'Orders', description: '…', images: '/cover.png' },\n}\n\\`\\`\\`\n\nA layout takes a title TEMPLATE - { template: '%s · Site', default: 'Site' } -\nand layouts merge outward-in, so site-wide values go on the root layout once.\n\n**Set metadataBase on the root layout. It is not optional in production.**\n\n\\`\\`\\`tsx\nmetadataBase: new URL('https://example.com')\n\\`\\`\\`\n\nA share-card scraper needs an ABSOLUTE image url and Facebook, Slack and\nLinkedIn refuse a relative one silently - the link unfurls with no image and\nnothing says why. metadataBase makes every relative url, image and icon\nabsolute. Same name as Next, so a port carries it across.\n\nUse the structured objects, not the flat 'og:title' spellings: openGraph and\ntwitter are typed, an image can be { url, width, height, alt }, and it is the\nshape a Next app already has. og: renders as property=, twitter: as name= -\nwhat each scraper reads.\n\nAn opengraph-image.png in app/ is found by name and needs no listing; it still\nneeds metadataBase to go out absolute.\n\nCharset and viewport: every document gets <meta charSet=\"utf-8\"> and\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"> as Next\nwrote them - do NOT add them to a ported layout to fix a desktop layout on a\nphone; a layout that renders either itself is left alone. To change it:\nexport const viewport: Viewport (from @rsc-kit/core/metadata) on a layout or\npage, Next's shape - width, initialScale, maximumScale, userScalable,\nviewportFit, themeColor (string or [{ media, color }]), colorScheme - merged\nouter to inner then the page.\n\ngenerateMetadata that reads params on a route that lists no urls: the PPR\nshell is one file for the whole pattern, so the build leaves that metadata\nout of it (no placeholder title baked in) and the host writes the real\ntitle and description into the head when it serves the shell for a url;\nthe client sets the title again after hydration. Nothing to do in the app.`,\n },\n {\n topic: 'fonts',\n summary: 'Self-hosted fonts from npm, and porting next/font',\n body: `There is no font loader. Install the font from Fontsource, import its\ncss, name it in a variable:\n\n\\`\\`\\`css\n@import '@fontsource-variable/fraunces/full.css';\n@import '@fontsource-variable/geist';\n\n:root {\n --font-display: 'Fraunces Variable', ui-serif, Georgia, serif;\n --font-sans: 'Geist Variable', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,\n 'Helvetica Neue', Arial, sans-serif,\n 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';\n}\n\\`\\`\\`\n\nPut the font in FRONT of a full stack, not in place of one. A bare\n'Geist Variable', sans-serif drops the emoji fonts - Geist has no emoji glyphs,\nand with nothing named after it some systems draw a box - and drops the\nmetrics-matched fallback that makes the swap moment smaller. Those are\nTailwind's own defaults; shadcn's generated line loses both.\n\nVite hashes the woff2 files and serves them with the other assets. Nothing is\nfetched from Google at runtime and nothing is downloaded at build - the files\nare in node_modules.\n\nPorting next/font: every option was something Fontsource already did.\nsubsets -> every subset ships behind a unicode-range and the browser fetches\nonly what the page uses. style: ['italic'] -> full-italic.css. axes -> full.css\nhas every axis; standard.css is weight only. display: 'swap' -> already in\nevery rule. className={font.variable} -> nothing, the variable is on :root.\n\nThe one line next/font added that you add yourself is the preload:\n\n\\`\\`\\`tsx\nimport fraunces from '@fontsource-variable/fraunces/files/fraunces-latin-full-normal.woff2?url'\n<link rel=\"preload\" href={fraunces} as=\"font\" type=\"font/woff2\" crossOrigin=\"anonymous\" />\n\\`\\`\\`\n\n?url is Vite's and gives the hashed path. Preload the one file the first paint\nneeds; preloading all of them defeats the subsetting.\n\nDo NOT reach for next/font, @next/font or a Google Fonts link tag.\n\nGETTING TO 100 ON A PHONE (do it last, once the design is settled). Fontsource's\nstylesheet declares every subset of every weight with font-display: swap; a\nthrottled phone audit sees the largest text repaint when the font lands, and\nscores 99. Three moves:\n1. Own the @font-face rules, latin only: import the files with ?url, declare\n them yourself with the latin unicode-range.\n2. font-display: optional for the body faces - the text paints once, in the\n fallback on a cold slow load, in the web font when cached. Keep swap for the\n headline face, which is preloaded and arrives with the document.\n3. One weight per family; variable only where the axes are used. Declare the\n range you ask for (font-weight: 400 500) so nothing requests a missing file.\nPut the preloads before the <style> with the faces. Measured on rsc-kit.dev:\nSpeed Index 1.7s to 0.9s, 99 to a steady 100, fonts 114 kB to 74 kB.\nTHE RULE: a font never blocks the page - text paints in the fallback before\nthe web font arrives. Three ways to break it, all avoided: a fonts.googleapis\n<link> (render-blocking CSS from a cold origin - self-host via Fontsource\ninstead), font-display: block or unset (invisible text for up to 3s - every\nrule says swap or optional), preloading every file (preload only what the\nfirst paint needs).\n\nAFTER THE ANSWER: after(() => sendEmail(user)) from @rsc-kit/core/request\nqueues work to run once the response is on its way - from an action, a\ncomponent, middleware or an api route. Do NOT use a detached promise: on a\nWorker the isolate dies with the response unless work is handed to\nwaitUntil, which after() does; on a process it runs detached. Rejections\nare logged, never surfaced.\n\nWRITE THE SCHEMA FOR THE SHAPE IT WANTS. The form is read the way the schema\nmeans it, on both sides (Zod 4 / ArkType describe themselves as JSON Schema;\nValibot not yet - its values arrive as strings):\n notify: z.boolean() // unchecked posts nothing -> false; \"on\" -> true\n limit: z.number().int() // \"5\" -> 5. NOT z.coerce.number()\n tags: z.array(z.string()) // one -> ['a'], none ticked -> []\n policy: z.string().optional() // hidden behind a switch -> absent when off\n + .refine((s) => !s.notify || s.policy) for \"required when the switch is on\"\nNested names nest: fields[0][name] / fields[0].name -> { fields: [{ name }] };\nauth[kind] picks a discriminated union's branch. No per-checkbox transform,\nno checkbox() helper. The action decodes the same object the form validated.\nOffline (rscKit({ offline: true })): the precache is what boots the app - js,\ncss, fonts, manifest, icons, / and /offline with their boot payloads; images,\nwasm and the share card are cached on first use. A frozen page never visited\nfalls back to /offline like any other navigation. Do not add a second service\nworker or a precache list; app/sw.js is importScripts'd into this one.\n\nAn action that redirect()s RESOLVES with { redirected: '/where' } on the\nclient once the navigation starts - it does not throw, so a plain\nstartTransition(async () => await logOut()) needs no catch (a rejection\nthere unmounts the root). The result is always an object (exactly one of\ndata / validationErrors / serverError / redirected is set), so reading any\nfield of it is safe. Do NOT wrap actions in a hook to catch\nServerRedirectError; nothing throws.\n\nBefore hydration a submit is a native POST to the page's url (React's hidden\n$ACTION_ fields); the host runs the action and re-renders the page with the\nresult seated in the <Form> that posted - a refusal shows on its fields\nwithout javascript; a redirect() is followed, a cookie lands. Nothing to\nconfigure; <Form> uses useActionState under a wrapper so the action keeps\nits (formData) signature.\nA blank control is absent for any optional field (z.email().optional()\naccepts it; an optional union is not read as its first branch) and \"\" for a\nrequired one (z.string().min(1) refuses it). A leaf\nwith no JSON Schema (z.date()) arrives as posted; its siblings still coerce.\n<Form ref={...}> is fine: the caller's ref is filled beside the form's own.\nRender props also carry dirty (anything differs from mount, uncontrolled\nfields included; baseline moves on a successful submit; reset() clears it):\n{({ dirty, reset }) => <Button disabled={!dirty}>Save</Button>} - the RHF\nisDirty gate for Save/Discard.\n\nFull guide: read_guide({ slug: 'fonts' }).`,\n },\n {\n topic: 'from-next',\n summary: 'Porting a Next.js app - what carries over, what to rename, what is different on purpose',\n body: `The app/ conventions are the same: layout, page, loading, error, not-found,\nroute.ts, [slug], [...path], (group), @slot, (.)intercept. \"use client\" and\n\"use server\" are React's. Copy src/app first, fix imports second.\n\nIMPORTS\n next/link -> @rsc-kit/core/Link (href typed; search typed by the page's schema)\n useRouter().push / .replace -> visit(url) / visit(url, { replace: true }) from @rsc-kit/core/router\n useRouter().refresh() -> refresh() from @rsc-kit/core/router, or revalidate() in the action\n usePathname / useSearchParams-> @rsc-kit/core/usePathname, @rsc-kit/core/useSearchParams (nuqs: @rsc-kit/core/nuqs)\n useParams() -> the page's params prop, passed down\n cookies(), headers() -> same names, from @rsc-kit/core/request. A cookie set in an action is what\n get()/has()/getAll() answer for the rest of that request, the sections it\n revalidates included - set('agent', id) then revalidate('all') is a whole switcher\n redirect() / notFound() -> @rsc-kit/core/redirect / @rsc-kit/core/not-found\n revalidatePath/Tag -> revalidate('name') on a section() - targeted, rides back with the action; the\n name is typed to the sections and slots the build found\n Metadata, Viewport -> @rsc-kit/core/metadata (metadataBase, openGraph, twitter, icons as-is;\n export const viewport as-is; charset + viewport tags are written for you)\n app/robots.ts, app/sitemap.ts -> the same files and shapes; app/llms.ts beside them (how_to seo-files)\n middleware.ts subdomain rewrite -> nothing: a host is a route segment (how_to domains)\n flags/next (Vercel Flags SDK) -> unchanged: the build aliases next/headers to @rsc-kit/core/request\n (how_to feature-flags); precompute() does not carry over\n next/font -> Fontsource (how_to fonts)\n next/image -> unpic or vite-imagetools (how_to images)\n next/script -> a <script> tag (how_to scripts)\n NEXT_PUBLIC_* -> PUBLIC_* in src/env.ts (how_to env); server vars typed there too\n import 'server-only' -> keep it (the build honours it). Under bun test the real package throws on\n import, so the scaffold's tests/preload.ts stubs it: bunfig.toml\n [test] preload = [\"./tests/preload.ts\"], mock.module('server-only', () => ({})).\n A project without those two files adds them before unit-testing an action.\n next-safe-action -> createActionClient() (how_to action-client); returnValidationErrors -> return fieldErrors({...})\n cache from 'react' -> cache from @rsc-kit/core/cache: React's dedupes only inside a render; this one\n spans the request (guards, actions, api routes). The build names files still on React's\n @react-email/render, renderToString in an action -> the same call, in a module that starts with \"use ssr\"\n (the build warns naming the app file and the package; the stub throws when called)\n (how_to emails). Next gets away with it only for externalised packages; here it is explicit\n import type { Route } from 'next' -> import type { Route } from '@rsc-kit/core/routes' (pages AND route.ts; a link to a route.ts is an anchor, never prefetched)\n\nDIFFERENT ON PURPOSE\n- No export const dynamic / revalidate = 60. A page is frozen unless it READS\n the request; await connection() is the explicit mark. No time-based ISR.\n- middleware.ts is per directory, on the server, full API; not one edge file.\n It does not run for actions - the check goes in the action.\n- Actions return failures ({ validationErrors }, { serverError }), not throw.\n- No image optimizer, no opengraph-image.tsx - put opengraph-image.png in src/app.\n- Tests need no browser: createTestApp() is the deployed handler. It builds\n with the project's own build script on the runtime the tests run under, and\n answers files the build wrote to .output/public (assets, sw.js, the\n manifest, icons) as production does - app.fetch('/sw.js') is a real test.\n- A component library (base-ui, Radix) imports as it did, from server\n components too. A shadcn-style components/ui/ folder keeps \"use client\" at\n the top of each file, as shipped; without it the server evaluates the\n library's internals for nothing.\n\nSCAFFOLD FLAGS: --host=bun|node|worker --validation=zod|valibot|arktype|none\n--env/--no-env (typed env vars via @t3-oss/env-core in src/env.ts, in the\nchosen library; server vars never reach the browser, PUBLIC_ prefix for ones\nthat may). Pick the library the Next app already uses.\n\nORDER: scaffold -> copy src/app -> fix imports -> build (it typechecks first,\nso a Link to a route that does not exist fails here) and READ the output: a\nroute that is not ○ names what streams and from which component (a cookies()\nin a layout reaches every page; the build says so) -> decide each action the\nbuild lists as running no middleware -> check.\n\nCONVERT THE FORMS AND ACTIONS - do not carry them. useActionState +\nuseFormStatus, react-hook-form, TanStack Form and useState-per-input all\nstill COMPILE here, which is why a port leaves them. Each becomes\n<Form action={…} schema={…}> (how_to forms) and a createActionClient()\nhandler (how_to action-client). Remove the form library when the last form\nis converted. A port that keeps two form systems has ported nothing.\n\nFull guide: read_guide({ slug: 'coming-from-next' }).`,\n },\n {\n topic: 'from-inertia',\n summary: 'Porting a Laravel + Inertia React app - what stays in Laravel, what the pages become, what goes',\n body: `Laravel stays, all of it: models, policies, form requests, middleware, jobs,\nthe session. What changes is who renders the page: Inertia rendered it in\nthe browser from props a controller assembled; here the page is a server\ncomponent rendered in front of Laravel that reads by name with rpc().\nInstall: composer require rsc-kit/laravel && php artisan rsc:install\n(runs rsc-kit init; one vite.config.ts, laravel-vite-plugin moved aside;\nroute tree is resources/js/app). See how_to backend for rpc(), attributes,\nmake:rsc-action and the manifest.\n\nWHAT EACH PIECE BECOMES\n resources/js/Pages/Orders/Index.jsx -> resources/js/app/orders/page.tsx (the file is the route)\n Route::get + Inertia::render(...props) -> nothing: the controller body moves to app/Rsc/Orders.php::recent(),\n the page does await rpc<Order[]>('Orders.recent', 20). Delete the route.\n usePage().props.auth.user, share() -> a read in the layout that shows it: rpc('Shared.auth'), passed as props.\n No global props bag. Deepest layout that needs it - a read in the root\n layout makes every page render per request (the build says so).\n usePage().props.flash -> the action's return value, back on the form that submitted\n <Link href={route('x', id)}> (Ziggy) -> <Link href=\"/orders/1\"> from @rsc-kit/core/Link, typed to the tree. Drop Ziggy.\n router.visit / router.get -> visit(url) from @rsc-kit/core/router\n router.reload({ only }) -> Rsc::revalidate('orders') in the action re-renders that section with the answer\n router.post, useForm().post('/orders') -> a server action: php artisan make:rsc-action Orders --method=create --auth\n --revalidate=orders, then <Form action={ordersCreate}> from @rsc-kit/core/form\n (import ordersCreate from the generated actions module)\n useForm errors/processing -> <Form>'s errors (a FormRequest's ValidationException lands on the fields)\n and pending\n ->middleware(['auth','verified']) -> export const middleware = ['auth', 'verified'] in middleware.ts beside the page\n Inertia::defer, <WhenVisible>, lazy() -> <Suspense> around the component that awaits; the shell streams first\n <Head title> -> export const metadata = { title }\n Page.layout = ... (persistent layouts) -> layout.tsx in the directory\n app.blade.php @inertia @vite -> resources/js/app/layout.tsx with <html><body>; no Blade root view\n createInertiaApp(), ssr.jsx, start-ssr -> nothing; init writes the entry; every page renders on the server\n usePoll -> usePolling (how_to live-data)\n <Link prefetch> -> the default (hover)\n Inertia::render('Error') -> error.tsx, not-found.tsx\n RedirectResponse from a controller -> redirect() from @rsc-kit/core/redirect in the page or guard;\n RscRedirectException from PHP is performed by the browser\n NProgress -> <PageTransition> from @rsc-kit/core/PageTransition, or nothing\n\nAuth pages: Auth.login with a LoginRequest calling Auth::attempt() - a cookie\nqueued during the call lands on the response; redirect('/dashboard') in the\nguard sends a signed-in visitor on. /login can also stay Laravel's (Fortify):\nany url the tree does not have is forwarded.\n\nDIFFERENT ON PURPOSE: no controller per page (a page reads three things,\ncalls three methods); no props bag and no partial reloads (sections\nrevalidate); / is the React tree's, welcome route or not; two processes\n(php artisan serve needs PHP_CLI_SERVER_WORKERS=4 and --no-reload); the\nbuild says what each page costs - most pages that read nothing per visitor\nfreeze, which Inertia never could.\n\nORDER: install -> move Pages/ into app/ one directory per url, layouts to\nlayout.tsx -> per page, controller body into app/Rsc, rpc() it, delete the\nroute -> npm run build and READ the output (a read in the root layout\nreaches every page; move it down) -> CONVERT THE FORMS (each useForm is a\nmake:rsc-action + <Form>; useForm still compiles here, which is why a port\nleaves it) -> replace route() with typed hrefs; tsc finds the rest -> build\nagain -> browser. Drop @inertiajs/react, laravel-vite-plugin and ziggy from\npackage.json when the last import is gone.\n\nFull guide: read_guide({ slug: 'coming-from-inertia' }).`,\n },\n {\n topic: 'feature-flags',\n summary: \"Vercel's Flags SDK (flags/next) runs unchanged: next/headers is answered by headers()/cookies() here\",\n body: `bun add flags. Then flags/next as written for Next:\n\n import { flag, dedupe } from 'flags/next'\n const visitor = dedupe(async ({ cookies, headers }) => ({ id: cookies.get('visitor')?.value ?? 'anon' }))\n export const showBanner = flag<boolean, { id: string }>({ key: 'show-banner', identify: visitor, decide: ({ entities }) => entities?.id === 'ada' })\n\n // page.tsx (server component)\n const on = await showBanner()\n\nThe build aliases next/headers to @rsc-kit/core/request - same names, same\nshapes (cookies().get(name)?.value), one object per request, which the SDK's\ndedupe keys on. Nothing to configure, no shim to write.\n\nA flag reads the request, so the page renders per visitor: put a <Suspense>\nor loading.tsx above the read and the build stores the rest as a shell (the\ntable says \"headers() in run, cookies() in run stream per request\").\n\nDiscovery endpoint: a route.ts -\n export const GET = createFlagsDiscoveryEndpoint(async () => getProviderData(flags))\n export const openapi = false\nprecompute() does NOT carry over (it rewrites urls in Next middleware); read\nthe flag in the page.`,\n },\n {\n topic: 'emails',\n summary: 'Render React to HTML on the server - an email, a PDF, a feed - from an action or a route, with \"use ssr\"',\n body: `@react-email/render, renderToString, anything on react-dom/server, called from\na server action or a route, fails: \"react-dom/server is not supported in React\nServer Components\". React means it: where server components render, react is\nthe server-only build - the renderer needs the client build's internals, and\nthe components it would render import that same react (no useState, no\nuseContext). No alias fixes it. The rendering has to run in the ssr\nenvironment, the one that turns pages into HTML for the browser.\n\nPut the rendering - the template AND the call that renders it - in a module\nthat starts with \"use ssr\". Everything else imports it normally:\n\n\\`\\`\\`tsx\n// src/lib/email/render.tsx\n\"use ssr\";\nimport { render } from '@react-email/render'\nimport { OtpEmail } from './otp-email'\n\nexport async function renderOtpEmail(code: string) {\n const email = <OtpEmail code={code} />\n const [html, text] = await Promise.all([render(email), render(email, { plainText: true })])\n return { html, text }\n}\n\\`\\`\\`\n\n\\`\\`\\`ts\n// src/lib/email/send-otp.ts - a plain server module, called from the action\nimport { renderOtpEmail } from './render'\nexport async function sendOtpEmail(to: string, code: string) {\n const { html, text } = await renderOtpEmail(code)\n await transporter.sendMail({ to, subject: 'Your code', html, text })\n}\n\\`\\`\\`\n\nWhere server components render, the build replaces the module with async\nproxies of its exports that call across - what \"use client\" does for a\ncomponent, in the other direction. Same process; dev and build; nothing to\nconfigure.\n\nRULES\n- Exports are async functions. The call crosses environments, so the answer is\n a promise. A sync function, a value, a class, export { } or export * is\n refused at build with its name. Types are fine.\n- Pass DATA across, not elements: renderOtpEmail(code), never\n render(<OtpEmail/>) from the caller. An element built on the calling side\n carries components from that side's react, and they render with no hooks.\n- The module's imports are the ssr side's: @react-email/components,\n react-dom/server, a PDF or Markdown renderer. Keep the module to rendering;\n the database call belongs on the calling side.\n\nImported react-dom/server directly (through a library, usually)? It now throws\nthe fix in its message, naming the app file that pulled it in, and the build\nwarns once with the same. Do NOT alias react-dom/server, externalise react, or\nmove the action out of the app - the directive is the whole fix.\n\nFull guide: read_guide({ slug: 'emails' }).`,\n },\n {\n topic: 'seo-files',\n summary: 'robots.txt, sitemap.xml and llms.txt from a file beside the root layout - the shapes Next uses, stored at build when they can be',\n body: `Files beside the root layout, named for what they answer:\n src/app/robots.ts -> /robots.txt default export returns MetadataRoute.Robots\n src/app/sitemap.ts -> /sitemap.xml default export returns MetadataRoute.Sitemap (an array)\n src/app/llms.ts -> /llms.txt default export returns MetadataRoute.Llms\n src/app/llms-full.ts -> /llms-full.txt default export returns a string\nThe types: import type { MetadataRoute } from '@rsc-kit/core/metadata'. The\nsame names and shapes as Next's app/robots.ts and app/sitemap.ts; copy them.\n\n\\`\\`\\`ts\n// src/app/robots.ts\nexport default function robots(): MetadataRoute.Robots {\n return { rules: [{ userAgent: '*', allow: '/', disallow: ['/api/'] }], sitemap: '/sitemap.xml' }\n}\n// src/app/sitemap.ts\nexport default async function sitemap(): Promise<MetadataRoute.Sitemap> {\n const posts = await db.post.findMany()\n return [{ url: '/', priority: 1 }, ...posts.map((p) => ({ url: \\`/blog/\\${p.slug}\\`, lastModified: p.updatedAt }))]\n}\n// src/app/llms.ts\nexport default function llms(): MetadataRoute.Llms {\n return { title: 'Acme', summary: 'What it is.', sections: [{ title: 'Pages', links: [{ title: 'Pricing', url: '/pricing' }] }] }\n}\n\\`\\`\\`\n\nA relative url is made absolute with the root layout's metadataBase; without\none it is a build error. Any of them may return a string, served as written.\n\nNO sitemap.ts? The build writes /sitemap.xml itself: every stored page and\nevery generateStaticParams url, lastModified = the build, minus guarded\nroutes (middleware.ts above them), failed pages and not-found. Needs the root\nlayout's metadataBase. Write sitemap.ts only when you need urls the build\ncannot see or per-url changeFrequency/priority.\n\nHOW FRESH - the function decides, the same rule every route follows:\n write nothing -> the build's own sitemap, stored; fresh every deploy\n sitemap.ts that reads the database -> stored at build (○); fresh every deploy\n sitemap.ts that awaits connection() -> rendered per request (ƒ); fresh every crawl\n\\`\\`\\`ts\nexport default async function sitemap() {\n await connection() // from '@rsc-kit/core/request' - per request, like a page\n return (await db.post.findMany()).map((p) => ({ url: \\`/blog/\\${p.slug}\\`, lastModified: p.updatedAt }))\n}\n\\`\\`\\`\nReading the database at build is fine; the REQUEST makes it dynamic, not the\ndata. Same for robots.ts and llms.ts. No middleware runs for\nthem - a root guard must not 401 the crawler. The url is typed\n(route('/sitemap.xml')).\n\nA file as written beside the root layout is served at the root as it is:\nrobots.txt, sitemap.xml, sitemap-*.xml, llms.txt, llms-full.txt, humans.txt,\nsecurity.txt, ads.txt. A file and a function for the same url is a build\nerror. Do NOT put these in public/ and do NOT write a middleware.ts for them.\n\nFull guide: read_guide({ slug: 'seo-files' }).`,\n },\n {\n topic: 'domains',\n summary: 'Subdomains and custom domains as route segments - admin.example.com reaches app/admin, a tenant host binds [domain], no rewrite',\n body: `A request from a host that is not the site's own is matched with the host\nin FRONT of the path. The site's own hosts: the root layout's metadataBase,\nwww. of it, and rscKit({ hosts: [...] }) - which is ONLY for a name that is\nneither the apex nor a subdomain of it (a staging/internal name, a second\nbrand domain); a normal setup needs no config beyond metadataBase. localhost\nand ips are always own.\n\n example.com/admin -> /admin app/admin/page.tsx\n admin.example.com/ -> /admin the same file (subdomain of an own host = its label)\n acme.example.com/settings -> /acme/settings app/[domain]/settings/page.tsx, domain \"acme\"\n acme.com/settings -> /acme.com/settings the same file, domain \"acme.com\" (other host = whole host)\n\nThe visitor's url is untouched; only the match changes. Only a top-level\n[domain] or [host] directory binds the host; app/[slug] or app/[collection] at\nthe top is a path parameter, as in Next. A top-level [domain]\nbinds ONLY from a host, never from a path: example.com/nope is a 404, not a\ntenant called \"nope\". Otherwise [domain] is an ordinary dynamic segment: params.domain in every page/layout under it, typed\nroute('/[domain]/settings', { domain }), loading/error files as usual. A\ndirectory named for a host (app/admin/) wins over [domain].\n\n\\`\\`\\`tsx\n// src/app/[domain]/layout.tsx\nexport default async function TenantLayout({ params, children }) {\n const { domain } = await params\n const tenant = await tenantByDomain(domain) // \"acme\" or \"acme.com\", as stored\n if (!tenant) notFound()\n return <TenantProvider tenant={tenant}>{children}</TenantProvider>\n}\n// src/app/[domain]/page.tsx - domains in a database: list them, they are stored at build\nexport async function generateStaticParams() {\n return (await db.tenant.findMany()).map((t) => ({ domain: t.domain }))\n}\n\\`\\`\\`\n\nOnly when a route could answer it (a top-level [domain] directory, or one\nnamed for the host); otherwise the host is the site's own. Keep metadataBase as\nthe production host: localhost and ips are always own, so dev routes by path.\nTo try a tenant locally: curl -H 'X-Forwarded-Host: acme.example.com'\nhttp://localhost:3000/ (or /etc/hosts). Behind a proxy the\nhost is X-Forwarded-Host, then Host. Not for a static export (a file server\nsees no host). Do NOT write a middleware rewrite, do NOT\nread the host in every page - the segment already is the host. The root layout\nneeds metadataBase (or rscKit({ hosts })) or every host is the site's own.\n\nFull guide: read_guide({ slug: 'domains' }).`,\n },\n {\n topic: 'identify',\n summary: 'What a response says about itself - X-RSC-Kit (how it was served, always) and X-Powered-By + a generator tag (what built it, off with identify: false)',\n body: `Every response carries X-RSC-Kit: stored | rendered | shell - a page from a\nfile the build wrote, rendered for this visitor, or a stored shell with its\nholes rendered now. The header to read when a page is slower than expected\n(like X-Nextjs-Cache); a CDN rule or health check can key on it. Names no\nproduct; always sent.\n\nBy default a response also says what built it: X-Powered-By: rsc-kit and\n<meta name=\"generator\" content=\"rsc-kit\"> in every document (BuiltWith,\nWappalyzer). The NAME only, never the version - a version in every response\nis what a vulnerability scanner filters on.\n\nrscKit({ identify: false }) turns off the name (header and tag) for a policy\nthat strips framework identifiers; X-RSC-Kit stays. Do not strip X-RSC-Kit\nat the proxy - it is what tells you whether a stored page was served.\n\nFull guide: read_guide({ slug: 'response-headers' }).`,\n },\n {\n topic: 'backend',\n summary: 'BAP (Backend-Answered Pages): a Laravel, Go or other backend behind the renderer - rpc() reaches it, middleware.ts names its middleware, app/Rsc/Actions are its server actions',\n body: `The model is a BAP - Backend-Answered Pages: a page rendered in front of\nthe backend rather than by it (MPA: backend renders; SPA: browser renders and\ncalls an API; BAP: a renderer on the server renders and calls the backend\nover loopback). The backend is the part that is not a page - models, session,\nauth, policies, jobs - answering one private endpoint, and it keeps every\nroute of its own. The whole model, and how to build for it:\nread_guide({ slug: 'backend-answered-pages' }).\n\nGo: in a Go module, rsc-kit init sees go.mod, writes the JS half and .env\n(RSC_BACKEND + a generated secret) and prints the Go wiring; go get\ngithub.com/rsc-kit/go. A new app: bun create rsc-kit --backend=<url>.\n\nA backend in another language answers that ONE endpoint, POST /__rsc/host-call,\nand the renderer wires itself from two variables in .env: RSC_BACKEND (a\nLaravel app's APP_URL counts) and RSC_HOST_CALL_SECRET. Both or neither.\n\nLaravel: composer require rsc-kit/laravel, then php artisan rsc:install. It\nruns rsc-kit init, which writes ONE vite.config.ts (laravel-vite-plugin is\nmoved aside - the renderer owns the frontend). Source is resources/js (the route tree is resources/js/app).\n\nrpc() is a global the renderer installs in its own process (never imported,\nnever in the browser bundle): one POST to the backend's host-call endpoint\nwith { function, args }, the secret and the visitor's cookie; the answer is\nthe return value as JSON, typed by rpc<T>(). Refusals arrive as their kind\n(422/401/403/redirect), never a 500; sibling calls in one tick are batched\nand each resolves the moment the backend answers it.\nA BAP server bundle carries no database driver, ORM or auth library - the\nbackend owns those.\n\nReach PHP from a server component - rpc() is a global, typed in\n.rsc-kit/rsc-env.d.ts, server render only:\n\n // app/Rsc/Orders.php: public function recent(int $limit): array\n const orders = await rpc<Order[]>('Orders.recent', 5)\n\nThe call runs AS THE VISITOR (their cookie is forwarded; auth()->user() is\nthem). Refuse with attributes: #[Authenticated], #[Can('update', Order::class)],\n#[Middleware('throttle:60,1')]. A ValidationException lands on the form as\nvalidationErrors; Authentication/Authorization exceptions answer 401/403.\n\nGuard a route in Laravel's vocabulary, no route declared in PHP:\n\n // resources/js/app/admin/middleware.ts\n export const middleware = ['auth', 'verified', 'can:update,post']\n\nMake one: php artisan make:rsc-action Orders --method=cancel --auth\n--can=update,Order --middleware=throttle:60,1 --revalidate=orders (--rpc for\nan rpc() class under app/Rsc; no --method = invokable; a slash nests,\nBilling/Invoices). Do NOT hand-write the attributes from memory; the command\nwrites the ones the registry reads, and it writes the map\n(rsc-host-actions.json) too - a running dev server restarts on its own when\nthe map changes, so the export is importable when the command returns.\nServer actions are classes in app/Rsc/Actions, found by reflection in PHP;\n\\`php artisan rsc:action-manifest\\` writes the map and MUST run before\nVite - the dev and build scripts rsc:install wrote do (\\`php artisan\nrsc:action-manifest && vite\\`), so a class written by hand is picked up by\nthe next \\`npm run dev\\` or \\`npm run build\\`, never by Vite alone. The\nbuild writes the \"use server\" stubs from the map - import ordersCancel from\nthe generated actions module in a client component. Rsc::revalidate('orders')\nin the action returns the re-rendered region with the answer.\n\nPer url: if the React tree has it, React renders it, otherwise Laravel does\n- including / : the welcome route in routes/web.php answers nothing while\nresources/js/app/page.tsx exists (the package registers the tree's urls from\nbootstrap/rsc/vite/routes.json after routes/web.php). Do NOT tell the user\nto delete the welcome route to make the page show; do not add Laravel routes\nfor React pages.\n\nphp artisan serve is one worker, which deadlocks the proxy - unless\nPHP_CLI_SERVER_WORKERS=4 in .env AND serve --no-reload (Laravel ignores\nthe variable otherwise). Herd, Valet, FPM, Octane are fine as they are. Production: put the renderer in front (bun\n.output/server/index.mjs with the app's .env), restrict /__rsc/host-call at\nthe web server.\n\nAny other language implements the same endpoint - request { function, args },\nreply { result | validationErrors | unauthenticated | unauthorized | redirect\n| error, revalidate }, answers '__rsc.middleware' with true or a refusal, and\na batch { calls: [...] } as application/x-ndjson - one line per call AS IT\nFINISHES, { index, status, ...reply }, in any order, flushed each time\n(X-Accel-Buffering: no) - or, less good, one JSON { replies: [...] } in\norder (every call then waits for the slowest). Calls issued in the same\nrender tick travel as one batch, so parallel reads are one backend request\nand a fast read still resolves while a slow sibling runs; the renderer falls\nback to single calls for a backend without batches.\n\nFull guides: read_guide({ slug: 'backend-answered-pages' }), read_guide({ slug: 'laravel' }), read_guide({ slug: 'go' }), read_guide({ slug: 'your-own-backend' }).`,\n },\n {\n topic: 'startup',\n summary: 'Once-per-process setup - src/instrumentation.ts is imported before any page and its register() awaited before the first request',\n body: `Setup that belongs to the process - validating env, configuring a shared\npackage, warming a connection - goes in src/instrumentation.ts. Do NOT import\na bootstrap module from pages to get the same effect; it depends on nobody\nforgetting, and the failure is a page throwing \"not configured\" for whoever\nreaches it first.\n\n // src/instrumentation.ts\n import './env' // refuses at import -> server fails at startup\n export async function register() { // optional; the first render waits for it\n await db.connect()\n }\n\nThe generated entry imports this file FIRST, so a package configured here is\nconfigured before any page module evaluates. register() is awaited by every\nentry point (server, dev, prerender, middleware, actions, api routes), once\nper process. On a server it runs at startup and a failure exits the process;\non a Worker it runs at the isolate's first request.\n\nWorker rule: read bindings INSIDE register(), not at the top of the module -\nprocess.env is empty until the first request arrives.\n\nA scaffolded app with env validation already has this file importing ./env.\nBuild machines without production variables: SKIP_ENV_VALIDATION=1.\n\nNever NODE_ENV in a .env: Vite sets it (development under vite, production\nunder vite build) and a .env line overrides it for the build, which then\ncompiles pages against React's dev JSX runtime (jsxDEV) and every route\nfails with React's opaque \"message omitted in production builds\". The build\nrefuses this and names the file:line. A plugin cannot override it (Vite\napplies the .env value after plugins run), so remove the line - other tools\nthat want it keep it in their own .env.\n\nFull guide: read_guide({ slug: 'instrumentation' }).`,\n },\n {\n topic: 'workers',\n summary: 'Cloudflare Workers: bindings (D1, R2, KV) from cloudflare:workers',\n body: `A D1 database, an R2 bucket or a KV namespace named in wrangler.jsonc is\nread from \\`cloudflare:workers\\`, the runtime's own module: the bundle leaves\nit external, like bun:sqlite. \\`vite dev\\` under Bun or Node has no such module,\nso import it lazily, behind a runtime check - never at the top of a module a\npage imports, or dev and the build fail to resolve it.\n\n\\`\\`\\`ts\nconst onWorkers = typeof navigator !== 'undefined' && navigator.userAgent === 'Cloudflare-Workers'\n\nexport async function db() {\n if (onWorkers) {\n const { env } = await import('cloudflare:workers')\n return env.DB\n }\n const { Database } = await import('bun:sqlite')\n return new Database('data/local.sqlite')\n}\n\\`\\`\\`\n\nThe build renders through the same module: a page that reads a table and\nnothing from the request is frozen from the local database at build time,\nand only pages that read the url or the request reach D1 per request. Do NOT\nput env.DB on a global or read it at import time. wrangler.jsonc: the\nd1_databases / r2_buckets entries wrangler printed at create time.`,\n },\n {\n topic: 'bun',\n summary: 'Running on Bun - Vite on Bun\\'s runtime (bun --bun vite), native deps external (serverExternalPackages), and the gotchas that are Bun\\'s not ours',\n body: `The vite bin has a node shebang: \\`bun run dev\\` alone starts Vite - dev\nserver, build, prerender - under NODE, and an app importing 'bun' or\n'bun:sqlite' fails at first render with \"Cannot find package 'bun'\". Scripts\non a Bun app: dev \"bun --bun vite\", build \"bun --bun vite build\". createTestApp\nruns the project's build script on the runtime the tests use.\n\nNative dependencies (sharp, bcrypt, better-sqlite3, @prisma/client, puppeteer,\n...) are external to the server bundles by default; Nitro traces them into\n.output/server/node_modules with their binaries. Add one:\nrscKit({ serverExternalPackages: ['@acme/native'] }). Same as Next's option.\n\nThe other direction: a dependency with \"use client\" files that does NOT\ndeclare react as a peerDependency (generated wrappers, workspace packages\nwith react under dependencies) would be left external by plugin-rsc and its\ndirective never read - hooks then run on the server. The build detects direct\ndependencies in that state and bundles them, printing\n\"[rsc-kit] bundling <pkg>: it has \"use client\" files but does not declare\nreact as a peer dependency\". Nothing to configure; fix the package's\npeerDependencies when it is yours.\n\nBun's, not the framework's: bun test loads the package .env (use\n--env-file=/dev/null to isolate); Stripe's constructEvent throws on Bun\n(no sync WebCrypto) - use constructEventAsync; Bun's pg puts SQLSTATE in\nerrno where Node's pg uses code.\n\nNever NODE_ENV in a .env (the build refuses it, naming the line). Build\nmachines without secrets: SKIP_ENV_VALIDATION=1.\n\nreflect-metadata (tsyringe, typeorm, inversify - often under\n@simplewebauthn/server): nothing to import; when the graph has it the build\nloads it in a Nitro plugin ahead of the app, so the \"tsyringe requires a\nreflect polyfill\" boot error does not happen in a directory or a binary.\n\nBuild, then start - NEVER run vite build while bun/node .output/server/index.mjs\nis serving from that .output: services load lazily and a failed import of a\nhalf-written chunk is cached by the runtime (ENOENT 500s until restart).\n\nSingle binary (Bun): bun build --compile .output/server/compile.mjs\n--outfile dist/app (the scaffold's \"compile\" script). compile.mjs is written\nby the build and embeds the frozen pages; with serveStatic: 'inline' in the\nNitro plugin the assets (and their .br/.gz) are inside too. Ship dist/app\nalone - a Dockerfile copies nothing else, not .output/public. --bytecode\ncompiles the JS ahead of time (cold boot 275 ms -> 72 ms measured, for an\nimage 81 MB -> 117 MB - on a pod the pull costs more than the boot saves).\nBytecode is CommonJS: a module using import.meta.env / .filename / .resolve\n/ bare import.meta (a dependency, usually) fails it, and Bun names no file\nand EXITS 0 - the binary dies at boot with \"import.meta is only valid\ninside modules\". The build prints the file and line at the end of a bun\nbuild when there is one; --bytecode --format=esm applies regardless. A production\napp ported from Next measured the binary image at 50.12 MiB against the\nNext image's 104.56 MiB, the docker build at 2m45s against 5m13s, and\nLighthouse at 99 mobile / 100 desktop - nothing tuned for the numbers.\n\nNothing is sent raw: a built bun/node server gzips what it answers\n(documents, streams flushed per chunk, payloads, stored pages, api routes)\nfor a request that accepts it, and the build writes .br/.gz beside every\npublic asset, served by Nitro. Nothing to configure; a Worker leaves it to\nthe platform. Off: compress: false on the handler / compressPublicAssets:\nfalse in Nitro config; Cache-Control: no-transform exempts one answer. Do\nNOT add a compression middleware or precompress assets yourself.\n\nFull guide: read_guide({ slug: 'bun' }).`,\n },\n {\n topic: 'openapi',\n summary: 'An OpenAPI document derived from route.ts files - rscKit({ openapi }) - and Scalar\\'s page over it, mounted as a route',\n body: `Do NOT hand-write an OpenAPI spec. rscKit({ openapi: true }) in\nvite.config.ts answers /openapi.json, derived from every route.ts: the\ndirectory is the path ([id] -> {id}), each method export an operation,\nparams/searchParams/body schemas the parameters and request body (Zod 4 and\nArkType describe themselves as JSON Schema; Valibot not yet), a middleware.ts\nabove a route a security requirement + 401/403. Stored at build, no middleware.\n\nDocument-level parts go on the option:\n rscKit({ openapi: { info, servers, security, components: { securitySchemes } } })\nWhat a route says about itself, beside its handler:\n export const openapi = { summary, tags, responses: { 200: {...} }, POST: { summary } }\n export const openapi = false // leave this route out (the reference page, a webhook)\n export const openapi = { DELETE: false } // one method out; HEAD/OPTIONS never documented\nWebhook-heavy app: rscKit({ openapi: { include: 'declared' } }) documents only\nroutes that export openapi, so callbacks need no opt-out line.\nResponse bodies are declared in openapi.responses until a typed helper exists.\n\nThe page: Scalar's own package, one route, nothing shipped by the engine:\n // src/app/reference/route.ts\n import { ApiReference } from '@scalar/nextjs-api-reference'\n export const GET = ApiReference({ url: '/openapi.json' })\n export const openapi = false\n\nPorting a spec file: delete its paths (they are the routes now, and body\nvalidates at runtime), move info/servers/security to the option, move a\nroute's summary/tags/responses to its openapi export.\n\nFull guide: read_guide({ slug: 'openapi' }).`,\n },\n {\n topic: 'env',\n summary: 'Typed environment variables - src/env.ts with @t3-oss/env-core in the app\\'s validation library; refused at startup by name. Never NODE_ENV in .env',\n body: `A scaffolded app has src/env.ts when it said yes to typed environment\nvariables (create-rsc-kit --env, with --validation=zod|valibot|arktype). To\nadd it to an app without one: install @t3-oss/env-core and write the same file.\n\n\\`\\`\\`ts\n// src/env.ts\nimport * as z from 'zod' // or valibot / arktype - any Standard Schema library\nimport { createEnv } from '@t3-oss/env-core'\n\nexport const env = createEnv({\n server: {\n NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),\n DATABASE_URL: z.url(),\n SESSION_SECRET: z.string().min(1),\n },\n clientPrefix: 'PUBLIC_',\n client: { PUBLIC_SITE_URL: z.url() },\n // No bare process.env: a \"use client\" file importing this for a PUBLIC_\n // value has no process, and the spread throws before the first render.\n runtimeEnv: { ...(typeof process === 'undefined' ? {} : process.env), ...import.meta.env },\n emptyStringAsUndefined: true,\n skipValidation: typeof process !== 'undefined' && !!process.env.SKIP_ENV_VALIDATION,\n})\n\\`\\`\\`\n\nRead env.DATABASE_URL, never process.env.DATABASE_URL: the first is typed and\nwas checked at startup (a missing or malformed one fails then, with its name),\nthe second is string | undefined. A server variable never reaches the browser;\na browser-readable one MUST start with PUBLIC_ and is read from import.meta.env\n(Vite; the engine registers PUBLIC_ beside VITE_ as a client prefix, nothing\nto configure), which is why runtimeEnv merges both. Commit .env.example, not .env.\n\nNext: NEXT_PUBLIC_* becomes PUBLIC_*; @t3-oss/env-nextjs becomes\n@t3-oss/env-core with runtimeEnv as above (env-nextjs's experimental__runtimeEnv\nis not needed).`,\n },\n {\n topic: 'live-data',\n summary: 'A value that keeps changing - realtime, live updates: usePolling over a query, or server-sent events (SSE, streaming from a middleware.ts generator) with useEvents - both feed TanStack, SWR or setState',\n body: `Neither is part of query() - a query answers once and is cacheable.\n\nPOLLING - start here when you have no change feed yet. Reuses the query,\ngoes through its Cache-Control (a CDN collapses many tabs into one origin\nread per interval), pauses when the tab is hidden, never overlaps two reads.\n\\`\\`\\`tsx\nimport { usePolling } from '@rsc-kit/core/usePolling'\nconst { data, status, refresh } = usePolling(() => fetchQuery(getSeats), { every: 2_000 })\n\\`\\`\\`\nUNTIL IT SETTLES - a job that ends. until(data) says the last read; onSettled\nfires once on it. The result is the DATA; what to\ndo on settling is the page's choice:\n // a server-rendered list, some jobs still running: re-render through the server\n usePolling(() => fetchQuery(jobStatus, [id]), { every: 2_000, enabled: !isTerminal(job), until: isTerminal, onSettled: () => refresh('page') })\n // the page that owns the job's state machine: the value in hand\n const { data, status } = usePolling(read, { every: 1_500, until: isTerminal, onSettled: (f) => dispatch(f.status) })\nSettled = stopped until refresh() or the inputs change. status: 'reading' |\n'paused' | 'settled' | 'idle'.\n\nSERVER-SENT EVENTS - when something can push. Better per update (bytes only\non change, instant), but holds a connection per open tab (fine on Bun/Node,\na limit on Workers or a small container), is uncacheable, and needs a source\nof change to yield from - a generator that polls the DB itself just moved the\npolling. An ordinary route.ts: beside its pages, runs middleware.ts above it.\n\\`\\`\\`ts\n// src/app/api/orders/[id]/events/route.ts\nimport { events, named } from '@rsc-kit/core/events'\nexport const GET = events(async function* ({ params, signal }) {\n const { id } = await params\n for await (const status of orderStatus(id, { signal })) yield { status }\n // yield named('paid', order, { id: order.id }) names a message / gives an id\n})\n\\`\\`\\`\n\\`\\`\\`tsx\nimport { useEvents } from '@rsc-kit/core/useEvents'\nconst { latest, all, status, close } = useEvents<Status>(\\`/api/orders/\\${id}/events\\`)\n// <Status> is the message type the route yields; a url infers nothing, so\n// without it latest is unknown. Declare the type beside the route, import both sides.\n\\`\\`\\`\nevents() frames JSON, sends a keepalive, sets text/event-stream + no-store,\nends the generator on disconnect (signal). EventSource reconnects itself and\nresumes with Last-Event-ID when you yielded ids.\n\nNO LIBRARY NEEDED. Both hooks ARE state: read data (polling) or latest\n(events) and render it. Neither needs TanStack or SWR.\n const { latest } = useEvents<Status>(url); const current = latest ?? initial // the server value until the first message\nWITH A STORE - when the value already lives somewhere, hand every value on so\nthat stays the truth: a useState, a reducer, or a cache library:\n useEvents<Order>(url, { onMessage: setOrder }) // useState\n useEvents<Order>(url, { onMessage: (m) => dispatch({ type: 'update', m }) }) // reducer\n useEvents<Order>(url, { onMessage: (m) => queryClient.setQueryData(['order', id], m) }) // TanStack\n useEvents<Order>(url, { onMessage: (m) => mutate(['order', id], m, false) }) // SWR\n usePolling(read, { every, onData: setSeats })\nSTABLE CALLBACKS: a callback a timer/subscription/listener calls that must see\nthe latest props is useEffectEvent from React (19.2+), never a ref assigned\neach render, and never in a dependency array. Both hooks are built on it.\n\nERRORS: both hooks expose error as state AND fire onError - a failed poll read\n(the next interval still reads) or a dropped stream (EventSource reconnects\nitself). Use onError for a toast/log; do NOT watch error in a useEffect.\nusePolling's onError gets (error, { failures }) - failed reads in a row, reset\nby a success - so toast on the third, not the first.\nfetchQuery(query, args) types args from the query: fetchQuery(getSeats) for a\nquery that takes nothing, fetchQuery(status, [{ id }]) refused if the query's\ninput has no id.\nDo NOT put a stream on query() or on a server action, and do NOT poll from\ninside an events() generator.\n\nFull guides: read_guide({ slug: 'queries' }) for polling, read_guide({ slug:\n'api-routes' }) for the streaming route.`,\n },\n {\n topic: 'images',\n summary: 'Responsive images with no optimizer - unpic for a CDN, imagetools for files in the repo',\n body: `There is NO image component and NO image server. Do not add next/image or\nwrite an optimizer route. next/image is a srcset-writing component plus a\nresize-on-request process; the first is a library, the second belongs to the\nCDN.\n\nAn image on a CDN (Cloudinary, imgix, Cloudflare Images, Bunny, Vercel,\nNetlify, ...): @unpic/react. Plain component, works in a server component,\nships no javascript, detects the CDN from the url:\n\n\\`\\`\\`tsx\nimport { Image } from '@unpic/react'\n\n<Image src=\"https://res.cloudinary.com/demo/image/upload/sample.jpg\" layout=\"constrained\" width={800} height={600} alt=\"...\" />\n\\`\\`\\`\n\nA file in the repo, a handful of them: vite-imagetools, resized ONCE at build\ntime. Add imagetools() to the vite plugins, then:\n\n\\`\\`\\`tsx\nimport hero from '../hero.png?w=400;800;1200&format=webp&as=srcset'\nimport heroSrc from '../hero.png?w=800&format=webp'\n\n<img srcSet={hero} src={heroSrc} sizes=\"(min-width: 800px) 800px, 100vw\" width={800} height={600} alt=\"...\" />\n\\`\\`\\`\n\nDeclare the query tails in src/images.d.ts, or the build's typecheck stops on\nthe imports (a pattern may hold ONE *, so '*?*' matches nothing):\n\n declare module '*&as=srcset' { const srcset: string; export default srcset }\n declare module '*&format=webp' { const url: string; export default url }\n\nHundreds of files in the repo: that is a CDN's job; move them and use unpic.\nAn icon or a logo: a plain <img>, or inline the svg.\n\nFull guide: read_guide({ slug: 'images' }).`,\n },\n {\n topic: 'scripts',\n summary: 'Third-party scripts - analytics, tag managers - without a Script component',\n body: `Write the script tag. React 19 does what Next's Script component existed for.\n\nAn external script with async, rendered from a server component, is HOISTED\ninto head and DEDUPLICATED by React - the same src in three components is one\ntag. That is afterInteractive:\n\n\\`\\`\\`tsx\n<script async src=\"https://www.clarity.ms/tag/abc123\" />\n\\`\\`\\`\n\nAn inline snippet renders where it is written and runs during parse, before\nhydration - the earlier moment, which is what an analytics snippet wants:\n\n\\`\\`\\`tsx\n<script id=\"ms-clarity\" dangerouslySetInnerHTML={{ __html: '...' }} />\n\\`\\`\\`\n\nPut site-wide scripts in the ROOT LAYOUT, which renders once and is kept\nacross navigations.\n\nThere is no Script component to import. The only case needing one - a script\nthat touches DOM React rendered, or an onLoad callback - is a client component\nwith useEffect that creates the tag. Ten lines of the user's own.`,\n },\n {\n topic: 'testing',\n summary: 'Unit-testing actions, queries and routes; the whole app without a port',\n body: `Almost everything is a function. Any test runner works.\n\n**Actions, queries, api routes: import and call.** \"use server\" is a string in\na test file, so the function is importable. An action built on the action\nclient runs its whole middleware chain when called and RETURNS its failures:\n\n\\`\\`\\`ts\nconst result = await createPost({ title: '' })\nexpect(result.validationErrors).toEqual({ title: ['too short'] })\n\\`\\`\\`\n\nAn api route takes a Request and the context the engine gives it - params is a\nPROMISE:\n\n\\`\\`\\`ts\nconst res = await GET(new Request('https://app.test/api/x'), { params: Promise.resolve({ id: '1' }) })\n\\`\\`\\`\n\n**Anything reading cookies() or headers():** open the request scope yourself.\n\n\\`\\`\\`ts\nimport { withRequest } from '@rsc-kit/core/request'\nawait withRequest(new Request('https://app.test/', { headers: { Cookie: 'session=abc' } }), currentUser)\n\\`\\`\\`\n\n**The whole app as Request -> Response, no port:**\n\n\\`\\`\\`ts\nimport { createTestApp } from '@rsc-kit/core/testing'\nconst app = await createTestApp()\nconst res = await app.fetch('/admin', { redirect: 'manual' }) // real router, real middleware\n\\`\\`\\`\n\nIt builds when the source is newer than the last build - the first run pays,\nthe rest do not. This is where a guard that never ran or a 404 that came back\n200 shows up.\n\n**What still needs a browser:** a server action called OVER THE WIRE (the id is\nReact's and private), hydration, navigation. Playwright against vite preview.\nThat limit is narrower than Next's: the action's logic is a unit test here.\n\n**What to write when you add something.** Before running check, not after:\n\n- A guarded route (middleware.ts, or a page reading the session): a stranger\n is turned away, and someone signed in gets 200.\n \\`\\`\\`ts\n expect((await app.fetch('/admin', { redirect: 'manual' })).status).toBe(302)\n expect((await app.fetch('/admin', { headers: { Cookie: 'session=ada' } })).status).toBe(200)\n \\`\\`\\`\n- An action: its refusal, by calling it. Bad input answers validationErrors;\n a stranger answers serverError (or throws ServerAuthenticationError if you\n built it without the client).\n \\`\\`\\`ts\n expect((await createPost({ title: '' })).validationErrors).toBeDefined()\n \\`\\`\\`\n- An action that takes an id: someone else's id is refused. This is the IDOR\n test and the one most often missing.\n- A query: the shape of its answer, and what a filter changes.\n- An api route: status, content-type, and the 4xx it answers to a bad body.\n- A page that should stay static: assert on the build report - no test, a CI\n check that build-report.json still says frozen for it.\n\nDo NOT start a dev server, spawn a process or pick a port in a test. Do NOT\nadd a second runner. The one in tests/ goes through the real build already.`,\n },\n]\n\n/** Every topic, with one line each — what a caller reads before choosing. */\nexport function listTopics(): string {\n return [\n 'Topics. Ask for one with how_to({ topic }).',\n '',\n ...RECIPES.map((r) => `${r.topic.padEnd(16)} ${r.summary}`),\n ].join('\\n')\n}\n\n/** One recipe, or the list plus a nudge when the topic is not one. */\nexport function howTo(topic: string): string {\n const wanted = topic.trim().toLowerCase().replace(/[\\s_]+/g, '-')\n const found =\n RECIPES.find((r) => r.topic === wanted) ??\n // A near miss is common and worth answering rather than refusing: someone\n // asks for \"form\" or \"queries\" and means the obvious thing.\n RECIPES.find((r) => r.topic.startsWith(wanted) || wanted.startsWith(r.topic)) ??\n // The summary is prose, so a multi-word ask is matched with its spaces back.\n RECIPES.find((r) => r.summary.toLowerCase().replace(/[-\\s]+/g, ' ').includes(wanted.replace(/-/g, ' ')))\n\n if (!found) return `No topic \"${topic}\".\\n\\n${listTopics()}`\n\n return `# ${found.topic} — ${found.summary}\\n\\n${found.body}`\n}\n\n/** For tests, so a recipe cannot be added without being reachable. */\nexport const TOPICS = RECIPES.map((r) => r.topic)\n"]}
|
package/guides/deployment.md
CHANGED
|
@@ -63,6 +63,33 @@ Change the preset and Nitro produces what that platform expects — a Worker and
|
|
|
63
63
|
its `wrangler.json`, a Vercel function, a Netlify handler. See
|
|
64
64
|
[Where it runs](/hosts/where-it-runs).
|
|
65
65
|
|
|
66
|
+
### Bindings on Workers
|
|
67
|
+
|
|
68
|
+
A D1 database or an R2 bucket named in `wrangler.jsonc` is read from
|
|
69
|
+
`cloudflare:workers`, the runtime's own module — the bundle leaves it to the
|
|
70
|
+
runtime, like `bun:sqlite`. `vite dev` under Bun or Node has no such module,
|
|
71
|
+
so import it where the code runs on Workers, behind a check for the runtime:
|
|
72
|
+
|
|
73
|
+
```ts title="src/db.ts"
|
|
74
|
+
const onWorkers = typeof navigator !== 'undefined' && navigator.userAgent === 'Cloudflare-Workers'
|
|
75
|
+
|
|
76
|
+
export async function db() {
|
|
77
|
+
if (onWorkers) {
|
|
78
|
+
const { env } = await import('cloudflare:workers')
|
|
79
|
+
|
|
80
|
+
return env.DB // the D1 binding, as wrangler.jsonc names it
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const { Database } = await import('bun:sqlite')
|
|
84
|
+
|
|
85
|
+
return new Database('data/local.sqlite')
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The build renders pages through the same module, so a page that reads a
|
|
90
|
+
table with no request in it is frozen from the local database, and only the
|
|
91
|
+
pages that read the url or the request reach D1 at request time.
|
|
92
|
+
|
|
66
93
|
## Two things that fail quietly
|
|
67
94
|
|
|
68
95
|
**Do not set `NODE_ENV` when starting the server.** The build bakes its mode
|
package/guides/domains.md
CHANGED
|
@@ -39,10 +39,12 @@ Listing a subdomain there makes it the site by path instead of a tenant —
|
|
|
39
39
|
the "app on `app.example.com`, marketing on the apex" split — which is a
|
|
40
40
|
choice, not a requirement.
|
|
41
41
|
|
|
42
|
-
And only when a route could answer it: a `[domain]` directory at
|
|
43
|
-
`app/`, or a directory named for the host.
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
And only when a route could answer it: a `[domain]` (or `[host]`) directory at
|
|
43
|
+
the top of `app/`, or a directory named for the host. Any other parameter
|
|
44
|
+
name at the top of `app/` — `[collection]`, `[slug]` — is a path segment, as
|
|
45
|
+
it is in Next. An app with a `metadataBase` and no tenant tree routes every
|
|
46
|
+
host by path, and a proxy that forwards to the app by an internal name is not
|
|
47
|
+
read as a tenant called `internal`.
|
|
46
48
|
|
|
47
49
|
The visitor's url is untouched: `acme.example.com/settings` stays in the
|
|
48
50
|
address bar, and a link to `/billing` on that page goes to
|
package/guides/index.json
CHANGED
|
@@ -242,7 +242,7 @@
|
|
|
242
242
|
{
|
|
243
243
|
"slug": "view-transitions",
|
|
244
244
|
"title": "View transitions",
|
|
245
|
-
"description": "
|
|
245
|
+
"description": "Navigations are an instant swap; a shared element is the transition worth having, and the page fade is the one to think twice about."
|
|
246
246
|
},
|
|
247
247
|
{
|
|
248
248
|
"slug": "where-it-runs",
|
package/guides/metadata.md
CHANGED
|
@@ -78,7 +78,16 @@ export default async function PostPage({ params }: { params: Promise<{ slug: str
|
|
|
78
78
|
|
|
79
79
|
It receives the same `params` and `searchParams` a page does, so there is one
|
|
80
80
|
shape to learn rather than two. Unlike a page's, these always resolve — a
|
|
81
|
-
`<title>` has no fallback, so there is nothing to suspend into.
|
|
81
|
+
`<title>` has no fallback, so there is nothing to suspend into. The one
|
|
82
|
+
exception is the shell of a route that lists no urls, where the params are
|
|
83
|
+
not known: a `generateMetadata` that reads them is left out of that shell,
|
|
84
|
+
and the host writes the real values in when it serves the shell for a url —
|
|
85
|
+
see [Partial prerendering](/guides/ppr).
|
|
86
|
+
|
|
87
|
+
A layout can export `generateMetadata` too, as in Next. Its answer stands for
|
|
88
|
+
the segments beneath it: a category named in its layout is the title of every
|
|
89
|
+
page under it that has no title of its own, and the template of the layout
|
|
90
|
+
above it still applies.
|
|
82
91
|
|
|
83
92
|
Metadata always resolves against the **full** layout chain, even when the
|
|
84
93
|
render is partial. A navigation that replaces one segment still produces the
|
package/guides/navigation.md
CHANGED
|
@@ -41,13 +41,94 @@ import { visit, prefetch } from '@rsc-kit/core/router'
|
|
|
41
41
|
<button onClick={() => visit('/checkout')}>Checkout</button>
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
-
Links prefetch on
|
|
45
|
-
is going before they
|
|
44
|
+
Links prefetch on their own — below. `prefetch` is for when you know where
|
|
45
|
+
someone is going before they show it:
|
|
46
46
|
|
|
47
47
|
```tsx
|
|
48
48
|
useEffect(() => { prefetch('/step-2') }, [])
|
|
49
|
+
useEffect(() => { prefetch('/step-2', undefined, { intent: true }) }, []) // and decode it now
|
|
49
50
|
```
|
|
50
51
|
|
|
52
|
+
## What a click finds already there
|
|
53
|
+
|
|
54
|
+
A navigation is as fast as what is in the browser when the click lands, and
|
|
55
|
+
a click that has to wait for the network is what a single-page app never
|
|
56
|
+
does. So a `<Link>` fetches its page before it is asked to, in two steps,
|
|
57
|
+
because the two cost differently:
|
|
58
|
+
|
|
59
|
+
- **The bytes, as the link comes into view.** The first dozen links on
|
|
60
|
+
screen per page, on every device, when the browser is idle, once per link,
|
|
61
|
+
at low priority so a real navigation overtakes the queue — the way Next
|
|
62
|
+
prefetches. A dozen, because a payload carries the whole page: a product
|
|
63
|
+
page with its related products is 30 KB, and a listing shows twenty-four of
|
|
64
|
+
them. A link past the budget is fetched on touch or on a settled hover, a
|
|
65
|
+
round trip before the click rather than before the scroll. The payload is
|
|
66
|
+
kept undecoded: thirty seconds for a page rendered per visitor, five
|
|
67
|
+
minutes for one the build made (the host marks it `public`, and only a
|
|
68
|
+
deploy changes it — which the [version handshake](/hosts/deployment#pages-across-a-deploy)
|
|
69
|
+
catches on the click). `cacheFor` on the link sets its own.
|
|
70
|
+
- **The pictures, as the bytes land.** Every `<img>` the page shows is a row
|
|
71
|
+
in its payload, so the ones it would load at once — not `loading="lazy"` —
|
|
72
|
+
are asked for at low priority the moment the payload arrives, the first six a
|
|
73
|
+
page — a product's own picture comes before its related ones. On a phone a product picture takes longer than the touch-to-click
|
|
74
|
+
a hidden render has, and the click should find it decoded. On intent the
|
|
75
|
+
same pictures are asked for again as urgent: a listing's tiles each
|
|
76
|
+
preload their product's picture, and the one tapped first was queued
|
|
77
|
+
behind the rest; asked for as high priority it moves to the front.
|
|
78
|
+
- **The decode, on intent.** Decoding a payload loads the client chunks the
|
|
79
|
+
page names, and a landing page decoding its Sign in link for every visitor
|
|
80
|
+
loaded the sign-in page's thirty chunks onto phones that never tapped. So
|
|
81
|
+
the decode waits for the signal that says which link is meant: a pointer
|
|
82
|
+
that has settled on the link for 100 ms, a press, a touch. Each of those
|
|
83
|
+
leads its click by 60–300 ms — about a round trip — and the chunks load in
|
|
84
|
+
that time rather than after the click.
|
|
85
|
+
|
|
86
|
+
The click then reads a decoded tree, changes the url, and hands the page to
|
|
87
|
+
React. Nothing on the network is between the click and the page unless the
|
|
88
|
+
link was never on screen, or the visitor asked for less data (Save-Data
|
|
89
|
+
turns the viewport prefetch off; the hover and the touch still fetch).
|
|
90
|
+
|
|
91
|
+
A link to a guarded page — every "Sign in" on a landing page points at the
|
|
92
|
+
app, and a signed-out visitor is sent to `/login` — is prefetched the same
|
|
93
|
+
way: the guard's answer is kept, and the destination is prefetched too, so
|
|
94
|
+
the tap asks for nothing.
|
|
95
|
+
|
|
96
|
+
### Measuring one
|
|
97
|
+
|
|
98
|
+
"Slow on my phone" needs a number, and the number it needs is the click to
|
|
99
|
+
the page on screen — not a request in a network panel. Every navigation
|
|
100
|
+
leaves it on the browser's timeline, in the User Timing API the Performance
|
|
101
|
+
panel of any dev tools draws:
|
|
102
|
+
|
|
103
|
+
| mark | when |
|
|
104
|
+
| --- | --- |
|
|
105
|
+
| `rsc-kit:navigate:start` | the click |
|
|
106
|
+
| `rsc-kit:navigate:decoded` | the payload decoded and the chunks it names loaded |
|
|
107
|
+
| `rsc-kit:navigate:applied` | handed to React |
|
|
108
|
+
| `rsc-kit:navigate` (measure) | start to the commit that put the page on screen |
|
|
109
|
+
|
|
110
|
+
In a console, after a few clicks:
|
|
111
|
+
|
|
112
|
+
```js
|
|
113
|
+
performance.getEntriesByName('rsc-kit:navigate').map((m) => Math.round(m.duration))
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
A document load the router chose — a route.ts, a foreign origin, a newer
|
|
117
|
+
build, a payload that was not one, a chunk the deploy no longer serves — is
|
|
118
|
+
announced before it happens, as a `rsc-kit:document-load` event on `window`
|
|
119
|
+
with the url and the reason, so a reload under a tester has a name:
|
|
120
|
+
|
|
121
|
+
```js
|
|
122
|
+
window.addEventListener('rsc-kit:document-load', (e) => console.log(e.detail))
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
A gap before `decoded` is the network or the chunks: the page was not on
|
|
126
|
+
screen to be prefetched, or was prefetched and never decoded — a scripted
|
|
127
|
+
click, a link with `prefetch={false}`. A gap after `applied` is the render,
|
|
128
|
+
which is the page's own size on the device's own CPU. On a phone through
|
|
129
|
+
Safari's Web Inspector (Develop → your iPhone → the page), the same entries
|
|
130
|
+
are in the Timelines tab.
|
|
131
|
+
|
|
51
132
|
## Showing progress
|
|
52
133
|
|
|
53
134
|
```tsx
|
|
@@ -82,10 +163,14 @@ navigated to. If the runtime never arrives, the browser gets the tap back
|
|
|
82
163
|
after four seconds as a document load. Only `<Link>`s are held — a plain
|
|
83
164
|
anchor, a `route.ts`, a file, a `mailto:` are the browser's as before.
|
|
84
165
|
|
|
85
|
-
A
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
166
|
+
A form whose action is a server action is held the same way. Before
|
|
167
|
+
hydration React renders it to post natively, so a page reloads with the
|
|
168
|
+
action applied — right with no JavaScript, and a full reload with JavaScript
|
|
169
|
+
a second away. Submitted in that window, the form waits for hydration and is
|
|
170
|
+
submitted again to the handler React then has on it; a tap on a link after
|
|
171
|
+
it wins, since the visitor left the form. With no runtime after three
|
|
172
|
+
seconds, it posts natively as before.
|
|
173
|
+
|
|
89
174
|
|
|
90
175
|
## Refreshing the current page
|
|
91
176
|
|
package/guides/ppr.md
CHANGED
|
@@ -45,13 +45,23 @@ wrong page.
|
|
|
45
45
|
|
|
46
46
|
The shell is stored per route **pattern**, not per url. `/posts/[slug]` has one
|
|
47
47
|
shell serving every slug, because everything that varies by slug is behind a
|
|
48
|
-
boundary the request fills.
|
|
48
|
+
boundary the request fills. A `generateMetadata` that reads the params is
|
|
49
|
+
left out of that shell — the build has no slug to read — and the host writes
|
|
50
|
+
the page's title and description into the head when it serves the shell for
|
|
51
|
+
a url it does know; the client sets the title again after hydration. Static
|
|
52
|
+
`metadata` on the page and the layouts is in the shell as always.
|
|
49
53
|
|
|
50
54
|
## What makes a page dynamic
|
|
51
55
|
|
|
52
56
|
Anything the build cannot know:
|
|
53
57
|
|
|
54
58
|
- **`await params`** — the url, for a route that has not listed its urls
|
|
59
|
+
- **`usePathname()`** in a client component, for the same route, answers
|
|
60
|
+
`""` in that shell: one shell serves every url it matches, so no link is
|
|
61
|
+
active and a breadcrumb is empty until hydration, when the hook moves to
|
|
62
|
+
the browser's url on its own. The payload such a document boots from
|
|
63
|
+
agrees with the shell, so there is no mismatch to repair. Where the url is
|
|
64
|
+
known — a listed url, a client navigation — the hook answers as always.
|
|
55
65
|
- **`await searchParams`** — the query string
|
|
56
66
|
- **`await headers()` / `await cookies()`** — the request
|
|
57
67
|
- **a host call**, on a host that has one
|
package/guides/routing.md
CHANGED
|
@@ -161,7 +161,7 @@ A default export is available too, if you prefer `router.visit('/about')`.
|
|
|
161
161
|
| Prop | Type | Default | What it does |
|
|
162
162
|
| --- | --- | --- | --- |
|
|
163
163
|
| `href` | `string` | — | The destination. An off-origin URL is left to the browser. |
|
|
164
|
-
| `prefetch` | `"hover" \| "mount" \| "click" \| "none"` | `"hover"` | When to warm the cache. `hover`
|
|
164
|
+
| `prefetch` | `"hover" \| "mount" \| "click" \| "none"` | `"hover"` | When to warm the cache. `hover` fetches the payload as the link comes into view, on every device, when the browser is idle, once per link — and decodes it, which loads the page's chunks, on intent: a pointer settled for 100 ms (so crossing a nav bar decodes nothing), a press, a touch. A click then finds everything there — [Navigation](/guides/navigation#what-a-click-finds-already-there). `true`/`false` are accepted as aliases for `hover`/`none`. |
|
|
165
165
|
| `cacheFor` | `number` | `30000` | How long a prefetched payload stays usable, in milliseconds. |
|
|
166
166
|
| `replace` | `boolean` | `false` | Replace the history entry instead of pushing one. |
|
|
167
167
|
| `preserveScroll` | `boolean` | `false` | Stay where you are instead of scrolling to the top. |
|
package/guides/server-actions.md
CHANGED
|
@@ -217,6 +217,29 @@ what you typed is still there.
|
|
|
217
217
|
is one, but the thing it wraps is a server component that fetches its own data,
|
|
218
218
|
and a client reference cannot be async.
|
|
219
219
|
|
|
220
|
+
Three things follow from an action that revalidated, and none of them needs
|
|
221
|
+
code:
|
|
222
|
+
|
|
223
|
+
- **What is re-rendered replaces what is on screen in place.** `revalidate('page')`
|
|
224
|
+
and `revalidate('all')` render the same tree a navigation to the page would,
|
|
225
|
+
so a form's state, a modal opened from `onSuccess`, a toast in the layout all
|
|
226
|
+
survive. A one-time secret shown after the action is still there.
|
|
227
|
+
- **The re-render sees the write.** A `cache()`d read the middleware or the
|
|
228
|
+
action made before the write is forgotten before the page renders again, so
|
|
229
|
+
a renamed row is renamed in the sidebar too, not only in the table.
|
|
230
|
+
- **What was fetched before the write is dropped.** Payloads prefetched for
|
|
231
|
+
other links and the pages held behind this one for the back button are from
|
|
232
|
+
before the mutation. A visit to the list after creating a row fetches the
|
|
233
|
+
list, with the row in it, rather than revealing the one prefetched a moment
|
|
234
|
+
earlier. `refresh()` does the same.
|
|
235
|
+
- **What comes back is for the page it was invoked on.** A tap that left the
|
|
236
|
+
page while the action was in flight — Add to cart, then the brand link at
|
|
237
|
+
once — changed what is showing, and the trees the host rendered for the
|
|
238
|
+
page before are not put under the url after. The page on screen was
|
|
239
|
+
fetched before the write, so it is asked for again whole, as `refresh('all')`
|
|
240
|
+
would; a modal opened over the page in the meantime does not count as
|
|
241
|
+
leaving it.
|
|
242
|
+
|
|
220
243
|
## Building one with a schema and middleware
|
|
221
244
|
|
|
222
245
|
A bare `"use server"` function takes whatever it is given and defends itself by
|
|
@@ -1,15 +1,27 @@
|
|
|
1
1
|
# View transitions
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> Navigations are an instant swap; a shared element is the transition worth having, and the page fade is the one to think twice about.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
A navigation here is an instant swap: the layouts stay mounted and the
|
|
6
|
+
page underneath changes, the way Next's router does it. Nothing fades, and
|
|
7
|
+
nothing needs to — there is no document reload to hide. **Most apps should
|
|
8
|
+
stop reading here.**
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
React 19.3 ships [`<ViewTransition>`](https://react.dev/reference/react/ViewTransition),
|
|
11
|
+
which animates a change by handing it to the browser's View Transition API,
|
|
12
|
+
and you can use it here for the one thing it is good at: a *shared element*
|
|
13
|
+
— a product image that morphs from the list into the detail page, a card
|
|
14
|
+
that grows into a panel. A fade over the whole page is a different thing.
|
|
15
|
+
It is cosmetic, it never makes a navigation faster, and on WebKit — Safari,
|
|
16
|
+
and every browser on iOS, Chrome included — it is expensive in a way a
|
|
17
|
+
desktop never shows; a port measured 600–900 ms with the screen frozen on
|
|
18
|
+
every navigation away from a long landing page before the transition was
|
|
19
|
+
taken out. If you want it anyway, the last section says how to have it
|
|
20
|
+
without paying that.
|
|
21
|
+
|
|
22
|
+
`<ViewTransition>` has one boundary worth knowing before you reach for it,
|
|
23
|
+
because the failure is silence: a transition that does not run looks exactly
|
|
24
|
+
like one you styled badly.
|
|
13
25
|
|
|
14
26
|
| the change comes from | animates |
|
|
15
27
|
| --- | --- |
|
|
@@ -60,78 +72,99 @@ comes back as an ordinary return value, and what you do with it is `useState`.
|
|
|
60
72
|
That makes it a transition like any other. `<Form>` is built on `useState`
|
|
61
73
|
throughout, so everything it drives animates the same way.
|
|
62
74
|
|
|
63
|
-
##
|
|
75
|
+
## A shared element across pages
|
|
64
76
|
|
|
65
77
|
A navigation replaces the segment inside `startTransition`, so a
|
|
66
|
-
`<ViewTransition>`
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
78
|
+
`<ViewTransition>` inside it animates like any other. Name the element that
|
|
79
|
+
should carry across — the same `name` on the list page and the detail page —
|
|
80
|
+
and the browser morphs it from where it was to where it is:
|
|
81
|
+
|
|
82
|
+
```tsx
|
|
83
|
+
<ViewTransition name={`product-${product.id}`}>
|
|
84
|
+
<img src={product.image} alt="" />
|
|
85
|
+
</ViewTransition>
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
One element, one snapshot the size of that element: cheap on every engine.
|
|
89
|
+
This is the transition worth having.
|
|
90
|
+
|
|
91
|
+
## The page fade, if you want it
|
|
92
|
+
|
|
93
|
+
What you cannot tell from outside is a navigation from the first commit
|
|
94
|
+
after hydration — the boundary taking over the server-rendered page, which
|
|
95
|
+
animated would be the page fading into itself on every load. So a
|
|
96
|
+
navigation's update carries the transition type `rsc-navigation`, and that
|
|
97
|
+
first commit does not. `PageTransition` keys the boundary on it, and skips
|
|
98
|
+
the fade where it is expensive:
|
|
72
99
|
|
|
73
100
|
```tsx title="src/app/layout.tsx"
|
|
74
|
-
import {
|
|
101
|
+
import { PageTransition } from '@rsc-kit/core/PageTransition';
|
|
75
102
|
|
|
76
103
|
export default function RootLayout({ children }) {
|
|
77
104
|
return (
|
|
78
105
|
<html lang="en">
|
|
79
106
|
<body className="min-h-full flex flex-col">
|
|
80
|
-
<
|
|
81
|
-
<div className="flex flex-1 flex-col">{children}</div>
|
|
82
|
-
</ViewTransition>
|
|
107
|
+
<PageTransition className="flex flex-1 flex-col">{children}</PageTransition>
|
|
83
108
|
</body>
|
|
84
109
|
</html>
|
|
85
110
|
);
|
|
86
111
|
}
|
|
87
112
|
```
|
|
88
113
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
`default: 'none'` is what makes a first load or a reload never
|
|
94
|
-
`<div>` matters (here it also carries the body's flex layout,
|
|
95
|
-
inside still fills the height): React names each top-level
|
|
96
|
-
animates every one on its own, so a page of
|
|
97
|
-
each morphing from where it was to
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
114
|
+
`PageTransition` is `<ViewTransition default={{ 'rsc-navigation': 'page', default: 'none' }}>`
|
|
115
|
+
around one `<div>`, with its CSS — there is nothing to add to your
|
|
116
|
+
stylesheet. On WebKit the class is `'none'`, decided after mount — see the
|
|
117
|
+
caution below for why — and so it is for a visitor who asked for reduced
|
|
118
|
+
motion. `default: 'none'` is what makes a first load or a reload never
|
|
119
|
+
animate. The `<div>` matters (here it also carries the body's flex layout,
|
|
120
|
+
so a `<main>` inside still fills the height): React names each top-level
|
|
121
|
+
element under a boundary and animates every one on its own, so a page of
|
|
122
|
+
five sections becomes five groups, each morphing from where it was to
|
|
123
|
+
where it is now. One wrapper element is one snapshot pair — the old page
|
|
124
|
+
and the new one dissolving into each other, the way Inertia's does. A
|
|
125
|
+
navbar inside the pair cross-fades into itself, which is invisible; put the
|
|
126
|
+
boundary around `<main>` in a nested layout instead when you want the
|
|
127
|
+
navbar left out of it entirely. Write the `ViewTransition` yourself,
|
|
128
|
+
without the wrapper, when you want the per-element movement.
|
|
103
129
|
|
|
104
130
|
### Shaping the animation
|
|
105
131
|
|
|
106
|
-
The
|
|
107
|
-
|
|
132
|
+
The component's CSS is the part that is easy to get wrong by hand, so it
|
|
133
|
+
ships with it: a 120 ms cross-fade, and `::view-transition { pointer-events:
|
|
134
|
+
none }`, because the pseudo-element tree sits over the document while the
|
|
135
|
+
fade runs and would otherwise swallow a second tap. The browser's default
|
|
136
|
+
is 250 ms, and the group and root pairs keep it whatever you set on the
|
|
137
|
+
page pair — a "120 ms" fade written as the two-selector rule most examples
|
|
138
|
+
show ran 290 ms. Two props adjust it:
|
|
108
139
|
|
|
109
|
-
```
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
::view-transition-new(.page) {
|
|
113
|
-
animation-duration: 120ms;
|
|
114
|
-
}
|
|
140
|
+
```tsx
|
|
141
|
+
<PageTransition duration={200}>{children}</PageTransition> // ms; 0 is the instant swap
|
|
142
|
+
<PageTransition webkit="run">{children}</PageTransition> // a short page that may run it on WebKit
|
|
115
143
|
```
|
|
116
144
|
|
|
117
|
-
`
|
|
118
|
-
|
|
119
|
-
|
|
145
|
+
`duration={0}` is the instant swap with the transition still in place for
|
|
146
|
+
the shared elements you name yourself (`<ViewTransition name="hero">` still
|
|
147
|
+
morphs). `webkit="run"` is for a page you have measured — a form, a settings
|
|
148
|
+
page — where the snapshot is one screen; the default skips WebKit for the
|
|
149
|
+
reason below. To shape the fade further, style `::view-transition-old(.page)`
|
|
150
|
+
and `::view-transition-new(.page)` yourself (the `transition` prop renames
|
|
151
|
+
the class); your rules come after the component's.
|
|
120
152
|
|
|
121
|
-
<Aside type="caution" title="WebKit snapshots the whole page">
|
|
153
|
+
<Aside type="caution" title="WebKit snapshots the whole page — which is why PageTransition skips it">
|
|
122
154
|
Safari — and every browser on iOS, Chrome included, since they all run
|
|
123
155
|
WebKit — rasterises the outgoing snapshot at the element's full height,
|
|
124
156
|
not the viewport's. A landing page 12,000 px tall is 44 megapixels at 3×:
|
|
125
157
|
a port measured ~600 ms with the screen frozen on every navigation away
|
|
126
158
|
from it, and the memory can be enough for iOS to discard the tab, which
|
|
127
159
|
comes back as a reload nobody asked for. Chromium caps the snapshot, so a
|
|
128
|
-
desktop or an Android phone never shows it.
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
160
|
+
desktop or an Android phone never shows it. Other sites' transitions run
|
|
161
|
+
fine on the same phone because they name the document root, which the
|
|
162
|
+
spec clips to the viewport; React's `<ViewTransition>` names the element
|
|
163
|
+
it wraps. So `PageTransition` runs the cross-fade on Chromium and Firefox
|
|
164
|
+
and skips it where `navigator.vendor` is Apple's — decided after mount,
|
|
165
|
+
so the first render agrees with the server's. Writing the boundary
|
|
166
|
+
yourself, do the same, or put it on a container that is the viewport's
|
|
167
|
+
height and scrolls inside, so the snapshot is one screen.
|
|
135
168
|
</Aside>
|
|
136
169
|
|
|
137
170
|
## Coming back to a page you were just on
|
package/package.json
CHANGED