@rsc-kit/mcp 0.30.20 → 0.30.22

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 CHANGED
@@ -194,7 +194,29 @@ RadioGroup are Radix underneath and render a hidden native control whenever
194
194
  given a \`name\` - omit it and they are invisible to the form.
195
195
 
196
196
  Do NOT use shadcn's own Form/FormField/FormControl with this. Those wrap
197
- react-hook-form, a different system for the same job. One or the other.`,
197
+ react-hook-form, a different system for the same job. One or the other.
198
+
199
+ A SEARCH ON THE PAGE IT IS ON: a GET form (action is a url string) whose action
200
+ is the page the visitor is already on keeps that page on screen while the query
201
+ runs - no shell is shown first, the old results stay, an input keeps what was
202
+ typed, and Back returns to the query it left. pending is true until the new
203
+ results are on screen:
204
+
205
+ \`\`\`tsx
206
+ <Form action="/lookup" method="get">
207
+ {({ pending }) => <><input name="q" /><button>{pending ? 'Searching…' : 'Search'}</button></>}
208
+ </Form>
209
+ \`\`\`
210
+
211
+ To show a skeleton where the results are instead of the old results, style them
212
+ from the form's own data-pending attribute (the results stay OUTSIDE the form, no
213
+ client wrapper): section:has(form[data-pending]) #results { display: none }, and
214
+ the same selector showing a #results-skeleton.
215
+
216
+ Do NOT patch around a skeleton on an in-page search with your own useState,
217
+ router.replace or a Suspense key: the form already does this. It is the form
218
+ only - a <Link> or visit() to the same path with another query is a page of its
219
+ own. A search to ANOTHER page still shows that page's shell first.`,
198
220
  },
199
221
  {
200
222
  topic: 'prefetch',
@@ -251,8 +273,9 @@ import Link from '@rsc-kit/core/Link'
251
273
  \`\`\`
252
274
 
253
275
  \`href\` is typed to the routes the build found, so a link to a page that no
254
- longer exists stops compiling. Cast with \`as Route\` only when the destination
255
- is genuinely computed.
276
+ longer exists stops compiling - and a url the backend owns is one once it is
277
+ named in backendPaths. Cast with \`as Route\` only when the destination is
278
+ genuinely computed.
256
279
 
257
280
  To prefetch from code — a row about to be clicked, a wizard's next step:
258
281
 
@@ -605,8 +628,12 @@ with <meta name="robots" content="noindex"> - while a search engine or
605
628
  link-preview crawler (matched by user agent) is answered once the whole page
606
629
  has rendered and gets the real 404 or redirect. For a person the redirect
607
630
  travels in the row's error digest and the browser
608
- performs it as a navigation, layouts kept. A late notFound() is the same: the
609
- browser asks for not-found.tsx and shows it where the page was, url unchanged.
631
+ performs it as a navigation, layouts kept. A destination that is not this app's
632
+ is a DOCUMENT LOAD, wherever the redirect is called: redirect('https://other.site/..')
633
+ type-checks as written, and so does a url the backend owns once it is named in
634
+ backendPaths (see the backend recipe) - no \`as Route\`. The url that redirected
635
+ is replaced in history either way, so Back does not return to a page that sends
636
+ you out again. A late notFound() is the same: the browser asks for not-found.tsx and shows it where the page was, url unchanged.
610
637
  A not-found.tsx beside a layout answers notFound() from every page under it,
611
638
  inside that layout - the nearest one above the page wins, like error.tsx, so a
612
639
  section keeps its header on a 404 (app/(shop)/not-found.tsx under
@@ -1615,6 +1642,12 @@ forwarded BEFORE any page is asked: rscKit({ backendPaths: ['/auth', '/github',
1615
1642
  '/gitlab'] }). A prefix at a segment boundary (/auth, not /authors), every
1616
1643
  method; '/' and /_rsc are refused; a team called auth is then unreachable. Do NOT
1617
1644
  write catch-all route.ts files that forward by hand: that copies the forwarding.
1645
+ A named prefix is also a DOCUMENT LOAD in the browser, never a page: a Link,
1646
+ visit() or redirect('/gitlab/connect') to it loads the document and is
1647
+ never prefetched, so the backend route may bounce on to another site (an OAuth
1648
+ start). NOT named, the router fetches it as a payload, the bounce to another
1649
+ origin is a hop a fetch cannot make, and the tap does nothing. Do NOT work around
1650
+ it with a client component that sets window.location.
1618
1651
 
1619
1652
  Laravel: composer require rsc-kit/laravel, then php artisan rsc:install. It
1620
1653
  runs rsc-kit init, which writes ONE vite.config.ts (laravel-vite-plugin is
@@ -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; read with error() and 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.\n\nONE WAY TO READ A FORM - two words, the same everywhere:\n error('title') the field's error, or undefined. !!error('title') is invalid.\n Nested fields are paths: error('address.city'), error('items[0].name').\n The names are TYPED from the action's input (a stub's typed\n parameter, or createActionClient().input(schema)), so\n error('titel') fails tsc: Field names. Open when the action\n says nothing (a FormData function, a url, a cast). A component\n below the form types its prop with FieldNamesOf<typeof action>\n from '@rsc-kit/core/form'. A WRAPPER you write around a stub\n (a function of a FormData) declares its fields on its\n parameter: (form: FormFields<'id' | 'name'>) - then error()\n is closed to them. A TEAM-SCOPED stub func(ctx, team, in) is a\n form's action as stub.bind(null, team): the form fills the\n struct after the bound args, and the names stay - but bind\n into a const first, inline bind() loses them. A stub whose\n parameters are plain strings has no struct for a form to fill:\n wrap it. A form that posts NOTHING (values in React state)\n names its fields on the form: <Form fields={['size']}>.\n formError the refusal NOT about a field (serverError - a 402/409 - or a\n form-level validation message), or undefined. Render it as\n {formError && <p role=\"alert\">{formError}</p>}; do not keep it\n from onError in your own state.\n field('title') a binding to spread - ONLY for a field whose value the UI must\n hold (a character count, a live preview, a control with no\n native element). Everything else stays uncontrolled.\nBeside them: pending, dirty, succeeded, recentlySucceeded, reset(), clearErrors(),\nand data (what is being submitted).\n\nThere is nothing else. No errors map, no fieldState, no useFormValues, no\nuseFormStatus - those were removed; if you see them in old code, convert them.\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, error }) => (\n <>\n <input name=\"title\" />\n {error('title') && <p>{error('title')}</p>}\n <button disabled={pending}>Save</button>\n </>\n )}\n </Form>\n )\n}\n\\`\\`\\`\n\nIn a child component, useForm() returns EXACTLY what the render prop gets, so\nthe code is the same:\n\n\\`\\`\\`tsx\nimport { useForm } from '@rsc-kit/core/form'\n\nfunction SubmitButton() {\n const { pending } = useForm()\n return <button disabled={pending}>Save</button>\n}\n\\`\\`\\`\n\n(Not React's useFormStatus - a different hook with a different answer.)\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\nA field is checked when it is LEFT, not as it is typed (the form listens for\nfocusout, so uncontrolled fields are covered), and on submit. So error() has\nnothing for a field nobody reached - there is no \"touched\" to check first.\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 error() takes the same path.\n\nBinding a field - a rich editor, a Radix select, a value read as it is typed:\n\n\\`\\`\\`tsx\n<Form action={save} defaultValues={{ body: '' }}>\n {({ field, error }) => (\n <>\n <Editor {...field('body')} />\n <span>{field('body').value.length}/100</span>\n {error('body') && <p>{error('body')}</p>}\n </>\n )}\n</Form>\n\\`\\`\\`\n\nfield() gives { name, value, onChange, onBlur } - react-hook-form Controller's\nfour. onChange takes a DOM event OR a bare value. A bound field is still an\nordinary named input, so it arrives in FormData with the rest.\n\nshadcn Field components, with the two words:\n\n\\`\\`\\`tsx\n<Field data-invalid={!!error('title')}>\n <FieldLabel htmlFor=\"title\">Title</FieldLabel>\n <Input id=\"title\" name=\"title\" aria-invalid={!!error('title')} />\n <FieldError>{error('title')}</FieldError>\n</Field>\n\\`\\`\\`\n\nFieldError renders its children and renders nothing when there are none.\n\nA bound field re-renders the form. For a LARGE controlled form, put the field in\nits own component and use useField - the same two words for one name, and it\nre-renders that component and nothing else:\n\n\\`\\`\\`tsx\nfunction Title() {\n const { field, error } = useField('title')\n return <Input {...field} aria-invalid={!!error} />\n}\n\\`\\`\\`\n\nA preview or summary reads a bound value the same way:\n\\`const { field } = useField('title'); field.value\\`. Only BOUND values can be\nread - an uncontrolled input's value is the DOM's.\n\nFor something that is NOT a descendant - a top bar - create the store above both\nand hand it in; useField(name, store) reads it:\n\n\\`\\`\\`tsx\nconst store = useFormStore({ title: '' })\n\n<TopBar store={store} /> // outside the form: useField('title', store)\n<Form action={save} store={store}>…</Form>\n\\`\\`\\`\n\nCreating the store does not subscribe to it, so the holder does not re-render\nper keystroke.\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\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; a refusal shows on its\nfields through error() even with no javascript.\n\n**components.json needs \"rsc\": true.** shadcn's init sees a plain Vite\nproject and writes false, and then a dialog or dropdown it adds has no\n\"use client\". create-rsc-kit --shadcn and rsc-kit init --shadcn set it; after\na bare \\`shadcn init\\`, set it by hand. \\`import { cn } from \"cn\"\\` in its\ncomponents is correct: cn is shadcn's own package (lib/utils re-exports it).\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.\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\nThe client components a prefetched page renders are loaded (code only) as its\npayload lands - the first eight per page, bounded per document - so the first\nvisit to a page does not wait on a chunk after the page arrived. Nothing to\nconfigure. Serve over HTTP/2 (Cloudflare does by default): every client\ncomponent is its own small chunk.`,\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'. Same rule: return fieldErrors(...). <Form>\nshows the fields as error('name') and the \"\" key as formError, and useAction\nreturns them as validationErrors; AWAITED DIRECTLY it rejects with\nServerValidationError (@rsc-kit/core/errors: .errors, .fieldErrors, .formErrors)\n- as does a backend stub whose input was refused - so code after the await does\nnot take a refused input for its result. One rule for a stub: anything that did\nnot happen REJECTS (ActionRefusedError, ServerValidationError); only a redirect\nresolves ({ redirected }), and isRedirected is the one check on a resolved value.\n\nWhen the INPUT is fine and the answer is still no - a project with orders\nattached, a plan without the feature - that is a refusal, not a field error.\nUse refuse(message, data), and declare the data's shape with .refusal(schema):\n\n\\`\\`\\`ts\n.refusal(z.object({ blockers: z.array(z.object({ id: z.number(), href: z.string() })) }))\n.handler(async ({ input, refuse }) => {\n if (attached.length) return refuse('Still in use', { blockers: attached.map(toLink) })\n})\n\\`\\`\\`\n\nThe message is result.serverError (a form's formError) and is never replaced\nby onError's generic message. The data is result.refusal, typed from the\nschema and checked against it (a form's formRefusal, typed from the form's\naction - no cast; a component below the form uses useForm<Values, Refusal>(). Do NOT encode blockers into the message or into\nfieldErrors({ '': ... }) - the links are lost. Do NOT re-derive them from the\npage's own list - a refusal decided at the moment of the write knows what is\nblocking it now. A backend refuses the same way (Laravel Rsc::refuse(), Go\nRefuseWith) and arrives as the same result.refusal. Data from an action with\nno .refusal(schema) is not sent. Through a generated stub (<Form\naction={ordersDelete}>) only the MESSAGE arrives, as formError: a plain\nfunction has no .refusal(schema), so the data is left out and the log says so -\nto act on it, call rpc() from a createActionClient action with .refusal(schema).\nAwaited DIRECTLY, a stub REJECTS with ActionRefusedError (@rsc-kit/core/errors:\n.message, .status), so code after the await does not run for a refused write -\nonly a redirect resolves. <Form> shows it as formError, useAction as serverError.\nA query built with .input(schema) takes that schema's input (a wrong key fails\ntsc); a query cannot redirect - throw ServerAuthenticationError (401) instead. Any backend refusal with a status, data or\nnot (Go Refuse(503, \"busy\"), Laravel abort(429)), and a backend's 401, 403 or\n404, arrive as result.serverError with the backend's message - never onError's.\nDo NOT write an onError that unwraps those; only a real failure reaches it. A\n.query() read rejects with the same message, and fetchQuery's error carries\n.status and .refusal.\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\nThe built action is typed by its schema: createPost({ title: 1 }) fails the\ntypecheck; FormData is accepted beside it (how <Form> calls it). It always\nresolves to an object - data | validationErrors | serverError | redirected.\n\nFrom a button, not a form - useAction (next-safe-action's useAction, and its\nuseOptimisticAction through React's useOptimistic):\n\n 'use client'\n import { useAction } from '@rsc-kit/core/useAction'\n const [shown, hide] = useOptimistic(posts, (list, id: number) => list.filter((p) => p.id !== id))\n const { execute, executeAsync, isPending, status, result, reset } = useAction(archivePost, {\n optimistic: ({ id }) => hide(id), // inside the transition; React reverts it on failure\n onSuccess: (data) => …, // typed from the handler\n onError: (message, result) => toast.error(message), // serverError, or the first validation message\n onSettled: (result) => …,\n })\n <button disabled={isPending} onClick={() => execute({ id })}>\n\nexecute never throws (a thrown failure becomes serverError); a redirect runs no\ncallback and sets no state; execute keeps one identity and reads the newest\ncallbacks.\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\nTHE DEFAULT PATTERN - write pages this way: the page is a SYNCHRONOUS\ncomponent; fixed copy, titles and frames go straight in it (they are the\nstored shell); each read is its own async \"slot\" component under its own\n<Suspense>, with a skeleton the shape of what it replaces:\n\n\\`\\`\\`tsx\nexport default function ShopifyPage() { // not async\n return (\n <Card>\n <CardTitle>Shopify</CardTitle> {/* in the shell */}\n <Suspense><ConnectButtonSlot /></Suspense> {/* nothing until known */}\n <Suspense fallback={<ConnectionsSkeleton rows={1} />}>\n <ConnectionSlot /> {/* async: its own read + check */}\n </Suspense>\n </Card>\n )\n}\n\\`\\`\\`\n\nEach slot does its own read and its own permission check, so a fast part is\nnot held back by a slow one, and a slot can move or be revalidated as a\nsection on its own.\n\nloading.tsx wraps the WHOLE page in one boundary. Use it only for a page that\nis one read and nothing else (a detail page whose skeleton is the page). Do\nNOT reach for it by default:\n- the heading/copy/frames the page already knows wait with the data;\n- the slowest read holds back every other part;\n- notFound()/redirect() or a backend 404 decided under it may already have a\n 200 on the wire for a person (the page then ends with a noindex tag; a\n crawler is answered with the finished page and gets the real 404) - a\n check that must be a 404 for everyone goes in middleware.ts, which runs\n before anything is sent;\n- it sits BELOW its layout: a layout that awaits (params, session) needs its\n own <Suspense> inside the layout;\n- a ROOT loading.tsx hides pages waiting in the wrong place (the build only\n warns \"nothing painted without the root loading.tsx\").\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\nFix that by moving the read into a slot under <Suspense>, not by adding a\nloading.tsx.\n\nA painted fallback stays up at least 300 ms - React's rule: content that\narrives within 300 ms of the fallback painting waits until then. A stored\nshell's holes arrive a moment after the shell, so fast data shows its\nskeleton for the full 300 ms (a flash on every app launch). So: no boundary\naround data that is always fast; fallbacks shaped like the content (same\ncard, same size, numbers blank); no animate-pulse on short-lived fallbacks.\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 status stays 200 for a person - a late notFound() ends the page\nwith <meta name=\"robots\" content=\"noindex\"> - while a search engine or\nlink-preview crawler (matched by user agent) is answered once the whole page\nhas rendered and gets the real 404 or redirect. For a person the redirect\ntravels in the row's error digest and the browser\nperforms it as a navigation, layouts kept. A late notFound() is the same: the\nbrowser asks for not-found.tsx and shows it where the page was, url unchanged.\nA not-found.tsx beside a layout answers notFound() from every page under it,\ninside that layout - the nearest one above the page wins, like error.tsx, so a\nsection keeps its header on a 404 (app/(shop)/not-found.tsx under\napp/(shop)/layout.tsx). A url no route owns is answered by the ROOT one only.\nAn error.tsx on the route never sees either - a redirect or a missing page is\nthe page's answer, not a failure, and nothing should check isNotFoundDigest -\nand 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 NEW app: bun create rsc-kit@latest my-app --pwa. It turns on offline and\nwrites src/app/manifest.ts, a static src/app/offline/page.tsx and starter icons\n(icon-192.png, icon-512.png, icon-maskable-512.png, apple-icon.png) - Chrome\nreports it installable with nothing missing. Replace the icons, keep the names.\nNothing in rsc-kit processes images.\n\nAn EXISTING app - 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 icon-maskable-512.png manifest only, purpose \"maskable\" (safe-zone padded)\n apple-icon.png <link rel=\"apple-touch-icon\">\n apple-splash-1179x2556.png iOS launch screen for that device, portrait\n opengraph-image.png <meta property=\"og:image\">\n twitter-image.png <meta name=\"twitter:image\">\n\n screenshot-wide-1280x720.png manifest screenshots, form_factor wide (desktop)\n screenshot-narrow-750x1334.png form_factor narrow (phone) - Chrome's richer install sheet\n\nSizes are read from the filename; no image is ever opened or resized.\n\nSet id: '/' in the manifest and never change it - without it the app's identity\nis its startUrl, and changing that orphans every install (the build warns).\nscope, orientation, categories and shortcuts are typed too.\n\nAn install button: useInstall() from '@rsc-kit/core/install' ->\n{ canInstall, install, installed, ios }. The offer (beforeinstallprompt) is\ncaught by the page's inline bootstrap, so a button that mounts late still gets\nit - do NOT add your own beforeinstallprompt listener in an effect, it can miss\nthe event. install() resolves 'accepted' | 'dismissed' | 'unavailable'. On iOS\nthere is no offer; when ios is true, tell the visitor Share -> Add to Home Screen.\n\n**Splash screens.** Android draws its own from the manifest: name, the 512px\nicon and backgroundColor - set all three. iOS needs one image per device and\norientation: name each apple-splash-WIDTHxHEIGHT.png at the device's pixel\nsize (landscape swaps them) and the build writes the media query from a device\ntable; a size no device has is named at build and linked nowhere. A Next port\ncan keep metadata.appleWebApp ({ capable, title, statusBarStyle, startupImage })\nunchanged. read_guide({ slug: 'pwa' }) has the size table.\n\n**One grey frame on a dark launch (iOS).** In dark mode an installed app can\nshow one frame of grey (~#2C2C2E) between the splash and the page: iOS starts\ndismissing the splash a frame before the page paints, and the empty web view\nshows through. Nothing in the page reaches it - not color-scheme, an html\nbackground, cards or placeholders. It is 1/60 s; do not chase it.\n\nThe 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. So does every other stored page, shells\nincluded; one that hydrates keeps its link as media=\"print\" so React finds\nit rather than fetching it again. Do not \"fix\" that link - it is not render\nblocking, and removing it makes React download the sheet after hydration.\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\nThe engine's server-only entries (request, redirect, revalidate, cache,\nsection, host, ...) are REFUSED in the browser bundle: a build or a dev load\nfails with \"'@rsc-kit/core/redirect' is for the server, and ended up in the\nbrowser bundle\" and the import chain. The cause is a helper file both a page\nand a client component import - split it. Mark your own server-only files with\nimport 'server-only' (the build honours it).\n\nA page never answers a request the browser marks as an image, script,\nstylesheet or font (Sec-Fetch-Dest): it gets a plain 404 before any middleware,\nlayout or backend call runs, so a dynamic /[team] route does not run its guard\nfor /favicon.ico. A route.ts still answers such a request - an image endpoint\nis a route, not a page - and a navigation or fetch() gets the page as always.\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\nA page's OWN share image is a file beside it: src/app/pricing/opengraph-image.png\n(and twitter-image.png) is the card for /pricing and the pages beneath it, the\nNEAREST folder winning - a route group like (marketing) counts as a folder, and\n[slug] covers every page it answers. Do not add a per-page tag to get this. A page\nthat exports openGraph.images replaces the site's image rather than adding a\nsecond og:image behind it; the nearer of the file and the export wins, and in the\nsame folder the file does. An openGraph with no images leaves the card to the file.\n_private, @slot and (.)intercepted folders are not searched.\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 never holds up a page: it streams under a boundary of its\nown, so the shell goes out at once and the <title>/<meta> arrive when the\nlookup finishes. A stored pattern shell has a hole where they go and the\nrequest fills it with the real values. A crawler is answered with the\nfinished page, metadata in <head>. Static \\`metadata\\` alone is in the first\nbytes. Nothing to do in the app - do not move a lookup out of\ngenerateMetadata to \"speed it up\".`,\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\nA generated backend stub (Go/Laravel actions in server-actions.generated.ts) is\ntyped Promise<T | Redirected>, not an ActionResult: the redirect answer is\n{ redirected } in place of T. Narrow with isRedirected from\n'@rsc-kit/core/errors' before reading it, and also before any \"success\" that\nfollows an await of a void stub:\n const secret = await databasesReveal(id)\n if (isRedirected(secret)) return\nReading secret.Password first is a type error on purpose. A <Form> or\nuseAction already skips onSuccess on a redirect.\n\ntsc does NOT see a redirect read as text: a template literal around a\nstring | Redirected compiles and goes to /t/[object Object]. The type-aware oxlint rules\ntypescript/restrict-template-expressions, no-base-to-string and\nrestrict-plus-operands do (a create-rsc-kit --lint project has them; run\noxlint src --type-aware, with oxlint-tsgolint installed). Nothing flags\n\"await stub(); toast.success()\" - check isRedirected there, and test a click:\nmount the component with happy-dom + react-dom/client + act, replace the stubs\nmodule (mock.module / vi.mock), and assert the redirected case shows no\nsuccess. Register happy-dom IN THAT TEST FILE and GlobalRegistrator.unregister()\nafter it, from a module the file imports FIRST (import './dom'), not a beforeAll:\na library checks for a DOM when it is imported (Base UI picks its layout effect\nfrom typeof document once), so a component imported earlier never opens its\ndialogs. Never in a global preload: it replaces Request/Headers/fetch, drops\nthe Cookie header, and createTestApp then refuses to run. Run bun test\n--isolate. See the testing guide, \"Components\".\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. bun test is\n not built: tests/preload.ts needs\n mock.module('next/headers', () => import('@rsc-kit/core/request'))\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_* -> VITE_* 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 useAction / useOptimisticAction -> useAction from @rsc-kit/core/useAction\n (optimistic: option + React's useOptimistic)\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, VITE_ 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 in the app, no shim to write.\n\nbun test is not built, so the alias is not there: flags/next finds Next's own\nnext/headers (a monorepo often has one) and throws \"headers was called\noutside a request scope\". One line in tests/preload.ts (the scaffold ships it):\n\n mock.module('next/headers', () => import('@rsc-kit/core/request'))\n\nThen a flag evaluates inside withRequest(), as in the app.\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\". A layout gets params too (its own segments' and those above, as a promise),\nand is rendered again when its own value changes. A route listing no urls\nstores one shell for all values, so a layout awaiting params above every\nboundary fails the build: read them under <Suspense> in the layout, or list\nthe urls with generateStaticParams. A tenant check that should 404 goes in\n[domain]/middleware.ts - its argument is the params, and notFound() there is\na real 404. 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 - the\nproject's first package.json if it has none - and .env (RSC_BACKEND + a\ngenerated secret) and prints the Go wiring; go get github.com/rsc-kit/go.\nA new app: bun create rsc-kit --backend=<url>, or rsc-kit init in an empty\ndirectory, which starts one there.\n\nThe backend writes rsc-host.json at the project root (beside vite.config.ts):\n{ \"actions\": { jsName: \"Function.name\" }, \"functions\": [\"Function.name\", ...] }.\nactions become \"use server\" stubs in src/server-actions.generated.ts;\nfunctions type rpc()'s first argument, so a misspelt name fails tsc. rpc is a\nGLOBAL the build declares in .rsc-kit/rsc-env.d.ts - never import it. Have\nVite write the manifest so it cannot go stale:\nrscKit({ hostManifest: { command: ['go', 'run', '.', '-manifest', '../rsc-host.json'], cwd: 'backend', watch: ['backend'] } })\n(watch: rerun when the backend's source changes under dev, so new functions are typed\nwithout a restart). Committed rsc-host.json/types? CI: rsc-kit-typegen --check fails\nwhen they are stale.\n(Go: reg.WriteManifest(path); Laravel: php artisan rsc:host-manifest).\nGo TYPED functions - prefer these: reg.Handle(\"Orders.recent\", func(ctx context.Context, limit int) ([]Order, error) {...})\nand reg.HandleAction(jsName, name, fn). Any positional params (structs, slices,\nmaps...), a trailing pointer is optional, variadic takes the rest. Their types\ngo into rsc-host.json, so rpc('Orders.recent', 5) is Order[] with no <T>, and\nthe action stub is typed and also accepts a FormData (fields -> the struct param:\nthe first, or the one after args bound with stub.bind(null, team)).\nA typed Go function's nil slice/map is sent as []/{} (only a nil pointer is null).\nA backend FAILURE in development carries its trace: the thrown error's cause is\n\"Backend <Type>\" with the PHP/Go frames, and the stack says \"Caused in the backend\nby ...\" - read that before guessing (Laravel: app.debug; Go: CallbackHandler.Debug\nor RSC_DEBUG=1). Never in production.\nA failed rpc() in a server component: catch and check\nerror instanceof ServerAuthenticationError (401) / ServerAuthorizationError (403)\nfrom '@rsc-kit/core/errors'; rethrow anything else.\nreg.Register(name, func(ctx, args rsckit.Args) (any, error)) stays untyped.\nLaravel: rsc:host-manifest types each app/Rsc method from its PHP signature -\nint/float/string/bool params (nullable, defaults optional, variadic), a\nFormRequest first param from its rules(), and a result that is a scalar, a\nbacked enum, or a plain class with public typed properties (a data object ->\nan interface). array/Model/Collection/JsonSerializable stay unknown: give\nthose rpc<T>(). Return a data object, not an array, to get a typed result. Tests\nanswer the backend with createTestApp({ host }) - how_to testing.\n\nWriting or changing an ADAPTER: it must pass the conformance suite before it\nships - register the Conformance.* functions (read_guide your-own-backend,\n\"Before you ship\"), serve them, and run\nnpx -y -p @rsc-kit/core rsc-kit-conformance --endpoint <url>/__rsc/host-call --secret <s> --manifest rsc-host.json\n(Go: cmd/conformance; Laravel: tests/Conformance are fixtures to copy).\nBAP vs Inertia for a Laravel team: read_guide bap-vs-inertia.\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\nA url the route tree does not own goes to the backend (/login, a webhook, an\nOAuth callback). One that it DOES match does not: a dynamic root route such as\n[team]/[app] matches every two-segment url, renders, says notFound() - and a page\nthat said that is never forwarded, so the backend's /gitlab/connect was the\napp's 404 (and sign-in looped) - Laravel's /login and /auth/* as much as Go's\nwhen the renderer is in front. Name the backend's prefixes, and they are\nforwarded BEFORE any page is asked: rscKit({ backendPaths: ['/auth', '/github',\n'/gitlab'] }). A prefix at a segment boundary (/auth, not /authors), every\nmethod; '/' and /_rsc are refused; a team called auth is then unreachable. Do NOT\nwrite catch-all route.ts files that forward by hand: that copies the forwarding.\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, or the status the backend gave: abort(429, 'Slow down')\nkeeps its 429 and its message), 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. An\naction shows an abort()'s message to the visitor as written, so give it one -\nabort(429, 'Slow down'); without one the form says \"Refused.\". Only a failure\nis replaced by onError's generic message.\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 manifest\n(rsc-host.json) too - a running dev server restarts on its own when it\nchanges, 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:host-manifest\\` writes rsc-host.json - { actions,\nfunctions } - and vite.config.ts runs it as dev and every build start:\nrscKit({ hostManifest: { command: ['php', 'artisan', 'rsc:host-manifest'] } }).\nfunctions types rpc()'s first argument, so a misspelt name fails tsc. The\nbuild writes the \"use server\" stubs from actions - 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. Batches are\nrequired: any other answer fails every call in it (at most 50 per batch;\n413 for more is fine).\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 and teardown - src/instrumentation.ts is imported before any page, its register() awaited before the first request, its shutdown() called when the server stops',\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 export async function shutdown() { // optional; called when the server stops\n await db.end()\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\nStopping is automatic: on SIGTERM/SIGINT a built server drains in-flight\nrequests (SERVER_SHUTDOWN_TIMEOUT, 5s), calls shutdown() (RSC_SHUTDOWN_TIMEOUT,\n10s; a hang or throw does not hold it) and exits 0. Do NOT add a SIGTERM\nhandler that calls process.exit() - it runs before the drain and drops\nin-flight requests. A logger flushes on 'exit', never on a signal.\n\nA file in src/commands (how_to commands) imports this one too, so the env is\nvalidated for it; its register() runs only for a command that exports\n\\`register = true\\`.\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: 'commands',\n summary: 'A release step or a worker is the same app started differently - src/commands/<name>.ts run as ./app migrate, or node .output/server/commands.mjs migrate',\n body: `Migrations before a deploy, a queue worker, a one-off script: a file in\nsrc/commands, with a default export. It shares the pages' bundle and\ndependencies (the database package, the logger), so there is no second build and\nno second binary.\n\n // src/commands/migrate.ts\n import { db } from '@repo/database'\n export const description = 'Apply the database migrations' // plain string; \\`--help\\` lists it\n export const register = true // opt in to instrumentation.ts's register()\n export default async function (args: string[]) {\n await db.migrate({ dryRun: args.includes('--dry-run') })\n }\n\n ./app migrate --dry-run # the compiled binary\n node .output/server/commands.mjs migrate --dry-run # a plain build (bun works too)\n ./app --help # lists them\n ./app # no arguments: the server, as ever\n\nThe return value is the exit code: nothing is 0, a number 0-255 is itself, a\nthrow is 1 with the error, any other return (a string, 256) is 1 and says so.\nAn unknown name exits 2. Return the code - do NOT process.exit() inside a\ncommand, it skips shutdown(). args is everything after the name, untouched:\n./app migrate --help gives migrate the --help.\n\nA command is a file DIRECTLY in src/commands; the name is the file's name. \\`*.d.ts\\`,\n\\`*.test.ts\\`, \\`*.spec.ts\\` and names starting with _ or . are not commands (_shared.ts\nis how two share a helper). A folder is not one: db/seed.ts is not db:seed, and\nthe build says so.\n\n./app --help and an unknown name are answered before the app loads (from a list in\nthe entry), so they need no valid environment. A real command loads the app:\nenv is validated (instrumentation.ts is imported, so \\`import './env'\\` refuses a\nbad environment) but register() does NOT run unless the command exports\n\\`register = true\\` - a migration should not fail on a queue it never touches. A\ncommand that opted in gets shutdown() after it returns, also after it throws.\n\nA worker is a command that waits. Its second argument is the context,\n{ signal, deadline } (the type is CommandContext, from '@rsc-kit/core/commands',\nwhich also has defineCommand to type a command without annotating it). signal\nis aborted by SIGTERM/SIGINT. Return when it aborts and the process runs\nshutdown() and exits 0; RSC_SHUTDOWN_TIMEOUT (10s) bounds it, a second signal\ndoes not wait. deadline is when the runner stops waiting (a Date.now() time),\nundefined until told to stop - budget the current job against\ncontext.deadline - Date.now(), do not re-read RSC_SHUTDOWN_TIMEOUT. deadline\nis a GETTER: do NOT destructure it ((args, { signal, deadline }) reads it once,\nat the start, when it is always undefined, and keeps that); read context.deadline\nwhen you need it. signal is a plain property and can be destructured.\n\nA stop reaches a command one of two ways, so a command that waits needs BOTH\nthe check and the listener. BEFORE it starts (while the app loads, tens of ms, or\nduring register()) the signal is ALREADY aborted and its 'abort' event has fired -\nthe runner gives the event loop one turn before calling the command, so a waiting\nstop is delivered first (the same on Node, Bun and a binary). AFTER it starts\n(the usual case) signal.aborted turns true and 'abort' fires while it runs. So:\ncheck signal.aborted as the command starts, and listen for 'abort' - or pass\nsignal to something that does ({ signal } to a queue, sleep(ms, undefined,\n{ signal })). Checking alone misses a stop during a long await; listening alone\nwaits for ever on a stop that came first. The loop below does both.\n\n import { defineCommand } from '@rsc-kit/core/commands'\n\n export default defineCommand(async (_args, { signal }) => {\n while (!signal.aborted) await queue.processNext({ signal })\n })\n\nThe entry holds SIGTERM/SIGINT from its first line, however long the app takes\nto load. In a compiled binary Bun first parses every module in the file (about\n170 ms for 580 modules and 119 MB), and a stop in that time is the default\naction: exit 143. --splitting makes the entry its own chunk, and the scaffold's\ncompile script has it: bun build --compile --splitting\n.output/server/compile.mjs --outfile dist/app. An app made before it keeps its\nold script until the flag is added; Bun 1.2 refuses it (\"cannot use --compile\nwith --splitting\"). The same stop 250 ms into an 80 MB\nbinary exits 143 without it and 0 with it. A plain build has no such window.\n--sourcemap with --compile embeds the map and ALSO writes .map files beside the\nbinary (one per chunk with --splitting: hundreds for a large app, dist/ in the\nhundreds of MB). The binary does not read them - a stack trace is the same with\nthem deleted - so ship the binary alone and rm -f dist/*.map, or leave\n--sourcemap off (the scaffold does).\n\nDo NOT run \\`node .output/server/index.mjs migrate\\`: index.mjs is the server and\nignores its arguments, so that starts the server. Commands are for the bun and\nnode presets only. On a Worker the build leaves src/commands out and says so\n(run the file in CI, or wrangler for D1); in \\`vite dev\\` run the file itself\n(\\`bun src/commands/migrate.ts\\`). The build lists them in .output/rsc-kit.json\nas \\`commands\\`.\n\nA scheduler is a command that keeps running (like Laravel's schedule:work): a\n\\`while (!signal.aborted)\\` loop that runs what is due and then\n\\`await sleep(60_000, undefined, { signal }).catch(() => {})\\` (sleep is\nsetTimeout from node:timers/promises), started as \\`./app schedule\\`. Run ONE\ninstance - two run every job twice, which is why a schedule does not belong in\nthe web server, which runs as many copies as it is scaled to. A job that runs\nonce a day is an ordinary command started by the platform's cron.\n\nFull guide: read_guide({ slug: 'commands' }).`,\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). With src/commands the same\nbinary is also ./app migrate (how_to commands). 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\". Use --bytecode --format=esm, which 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 renderer, one route, nothing shipped by the engine:\n // src/app/reference/route.ts (bun add @scalar/client-side-rendering)\n import { renderApiReference } from '@scalar/client-side-rendering'\n export const GET = () => new Response(renderApiReference({ config: { url: '/openapi.json' } }),\n { headers: { 'Content-Type': 'text/html; charset=utf-8' } })\n export const openapi = false\nNOT @scalar/nextjs-api-reference: same call plus a theme, but its peer\ndependency on next makes npm/pnpm/bun install all of Next.js into the app.\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: 'secrets',\n summary: 'The secrets rsc-kit reads - RSC_HOST_CALL_SECRET, RSC_SIGNING_SECRET, RSC_ACTION_ENCRYPTION_KEY - what each protects, how to generate them, where they go in production, and what rotating one does',\n body: `Three, each its own key for its own job. Never reuse one as another.\n\n RSC_HOST_CALL_SECRET renderer <-> Go/Laravel backend calls. Required with a\n backend; read by the renderer AND the backend.\n RSC_SIGNING_SECRET signs refreshOn names a page hands the tab. Required in\n production when any page/section uses refreshOn (the\n server refuses to serve without it). Renderer only.\n Never falls back to the host-call secret.\n RSC_ACTION_ENCRYPTION_KEY optional: pins the key server actions encrypt with, so\n actions survive a deploy. Set at BUILD time too.\n\nGENERATE - once each, a different value each:\n openssl rand -base64 32\n node -e \"console.log(require('crypto').randomBytes(32).toString('base64url'))\"\n php -r \"echo base64_encode(random_bytes(32)), PHP_EOL;\"\nDevelopment: create-rsc-kit / rsc-kit init (with a backend) and\nphp artisan rsc:install write the first two into .env already.\n\nPRODUCTION - in the platform's environment settings, never in git:\n- the SAME value on every instance (a tab served by one renderer is checked by\n another; a call may land on any backend replica);\n- read at runtime, so one build deploys anywhere (except the action key).\n\nROTATING is a deploy:\n- host-call: change renderer and backend together; between them every rpc() is refused.\n- signing: open pages stop refreshing until reloaded; nothing else breaks.\n- action key: actions from pages loaded before the change fail.\n\nDocs: /hosts/deployment#secrets.`,\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: 'VITE_',\n client: { VITE_SITE_URL: z.url() },\n // No bare process.env: import.meta.env carries the VITE_ values, and the\n // file does not throw in the browser if a client component imports it.\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 VITE_ and is read from import.meta.env\n(Vite's own prefix and the only one - a PUBLIC_* variable, once accepted\nbeside it, fails the build by name), which is why runtimeEnv merges both. Commit .env.example, not .env.\n\nCLIENT COMPONENTS DO NOT IMPORT src/env.ts. A \"use client\" file (or a lib file\none imports) reads import.meta.env.VITE_X directly, declared in\nImportMetaEnv. Importing env there ships the validation library to the\nbrowser - measured: 20 kB gzipped of Zod + env-core in a chunk that needed one\nurl. The value is still validated: the server imports env at startup.\n\nNext: NEXT_PUBLIC_* becomes VITE_*; @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: 'refresh-on',\n summary: 'Refresh a page or section when the backend says its data changed - a webhook, a job, another user - with nothing polling: refreshOn on a section + Changed / Rsc::changed / changed(); server-initiated revalidation; where revalidateTag was',\n body: `A section names the data it shows; whatever changes it says so, from\nanywhere; every open tab showing it refreshes. No polling, no stream to\nwrite, no library.\n\\`\\`\\`tsx\n// src/app/t/[team]/repos.section.tsx\nimport { section } from '@rsc-kit/core/section'\nconst ReposSection = section('repos', Repos, { refreshOn: ({ params }) => [\\`team:\\${params.team}:repos\\`] })\nexport default ReposSection // named, so the editor offers it as an import: a bare export default section(...) is offered as reposSection\n// a page: export const refreshOn = ({ params }) => [...] -> a change refreshes the page\n// typed from its schemas: export const refreshOn: PageRefreshOn<typeof params> = ({ params }) => [...]\n// (import type { PageRefreshOn } from '@rsc-kit/core/section'; params arrive awaited + parsed)\n// no schema: PageRefreshOn<'/[team]/[app]/[env]'> types params from the route's pattern;\n// a misspelt param or a pattern that is not a route fails the typecheck\n// export const / function / { refreshOn } / { refreshOn } from './x' all count; export * from does not\n\\`\\`\\`\nA LAYOUT'S REGION - what is the same on every tab (a title, a status, the tab bar)\nbelongs in the layout, which stays mounted while the pages under it change, and\nwhen it shows data that changes it is a section there. A page refreshes the\nsections beside it and then those beside its layouts, nearest first, so\nrevalidate('header') from an action on any tab, or a refreshOn change, renders\njust that. Put it in its own <Suspense> in the layout (it reads params, and a\nlayout that awaits them above every boundary fails the build). Pass\nparams={params} through; a refresh renders the section with the page's props, which hold every segment the layout's do. A name\nis unique along the chain (the nearer one wins). The active tab is usePathname()\nin a client component, not a refresh. Do NOT draw the header in each tab's page\ninside its Suspense: a tab switch mounts a new page, and its fallback replaces\nthe header until the data arrives.\nPARENT AND CHILD - a change to a child changes its parent's list: say both\nwhere the write is - changed(\\`app:\\${id}\\`, \\`team:\\${teamId}:apps\\`) - and let each\nregion name only what it shows. Never make a list watch every child's name.\nNOTHING ANNOUNCES IT (a host's logs, a third-party status) - usePolling, not refreshOn.\nTEST IT - (await createTestApp()).watched('/teams/acme') -> { page: [...], apps: [...] },\nthe names each region is handed; [] means refreshOn gave none (also warned in dev).\nSAY IT CHANGED - wherever the change happens, not only in an action:\n Go: reg.Changed(ctx, \"team:\"+teamID+\":repos\") // the webhook handler, a job\n Laravel: Rsc::changed(\"team:$teamId:repos\"); // a controller, a job, a listener\n JS only: import { changed } from '@rsc-kit/core/changed'; await changed(\\`team:\\${team}:repos\\`)\nANY DATABASE - rsc-kit ships no driver; hand it yours. Postgres:\npostgresVersions(sql) (postgres.js/Bun.sql, or Drizzle's db.$client) - one\nstatement per change, instant via LISTEN. MySQL/MariaDB: sqlVersions({\ndialect: 'mysql', query: async (t, p) => (await pool.query(t, p))[0] })\n(mysql2). SQLite: sqlVersions({ dialect: 'sqlite', query: async (t, p) =>\ndb.query(t).all(...p) }). libSQL/Turso: sqlVersions({ dialect: 'sqlite',\nquery: async (sql, args) => (await client.execute({ sql, args })).rows }).\nAlways pass dialect: without it a change is a read + write per name.\nRedis/ORM query builders: createVersions({ read, bump, listen?, notify? }).\nInstant across instances: postgres.js LISTEN, Redis pub/sub; others within ~1s.\nNO BACKEND (JS only) - changed() works as-is on one server. With more than\none instance, or a worker process that finishes jobs, keep versions in a\ntable they all share: installVersionSource(postgresVersions(sql)) in\ninstrumentation.ts register() AND in the worker (postgres.js LISTENs, so\ncross-instance changes are instant; so does Bun.sql (Bun 1.4+); any SQL\ndriver/ORM: sqlVersions({ query }) - Prisma: query: (t, p) =>\nprisma.$queryRawUnsafe(t, ...p); anything else (Drizzle query builder, Redis,\nKV): createVersions({ read(names), bump(names), listen?, notify? }) and\nrsc-kit does the waiting/waking). An app-installed store always wins over a\nbackend's. In-memory versions fail with 2+ instances; in production rsc-kit logs a\nwarning once when it falls back to them - installVersionSource(memoryVersions())\nsays \"one instance on purpose\" and silences it.\nA DIRECT DATABASE EDIT (psql, a migration, an admin tool, another app) says\nNOTHING: nothing watches tables, so open tabs stay stale until refreshed (a reload\nreads fresh - the data is not wrong). Make the WRITER call changed() after the\ncommit; or put a TRIGGER on the data table that upserts the rsc_versions row in\nthe same transaction (moves with the commit, a rollback moves nothing) and, on\nPostgres, pg_notify('rsc_versions', '') so tabs hear at once - without it up to\n30s with a listening store, 1s without; the name must be spelt as refreshOn\nspells it. usePolling only where nothing can say it.\nTable: rsc_versions(name TEXT PRIMARY KEY, version BIGINT NOT NULL) - same as\nGo's SQLVersions. ANY process can bump without rsc-kit, with one upsert that\nmoves version to GREATEST(version + 1, now in ms) - Postgres:\nINSERT ... VALUES (name, (EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint)\nON CONFLICT (name) DO UPDATE SET version = GREATEST(rsc_versions.version + 1,\nEXCLUDED.version); then NOTIFY rsc_versions. MySQL: GREATEST(version + 1,\nVALUES(version)) with FLOOR(UNIX_TIMESTAMP(NOW(3)) * 1000); SQLite: MAX(...)\nwith CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER).\nCLEANUP - a version is the time a name last changed, so deleting old rows is\nalways safe (a tab holding one refreshes once; it never comes back at a value\na tab holds): versions.prune() on sqlVersions/postgresVersions (30 days), or\nDELETE FROM rsc_versions WHERE version < <ms 30 days ago> from a cron.\nmemoryVersions forgets after 30 days; the server forgets names no tab\nwatches. Go: store.Prune(ctx, 0) on SQLVersions/MemoryVersions. Laravel:\ncache keys expire after RSC_VERSIONS_KEEP_DAYS (30); with\nRSC_VERSIONS=database schedule php artisan rsc:prune-versions daily. A custom store's bump MUST use nextVersion(current) - never + 1, which repeats once a row is pruned.\nGO, SEVERAL INSTANCES - SQLVersions is noticed within ChangedPoll (1s). For\nat once: SQLVersions{..., Notify: \"rsc_versions\"} plus reg.WakeOn(ctx,\nlistener) where the listener (pgx: LISTEN rsc_versions, WaitForNotification)\ncalls connected() then wake() per notification. One instance needs neither.\nWORKERS, ONE HUB - by default each isolate watches its own tabs: one ask per\ntab every 2s. nitro({ preset: 'cloudflare_durable' }) plus wrangler.jsonc\ndurable_objects binding \"$DurableObject\" (class \"$DurableObject\", migration\nnew_sqlite_classes) and vars RSC_CHANGES_HUB: \"$DurableObject\" sends every\ntab's stream to one Durable Object: one ask for all tabs, changed() in any\nisolate wakes it at once (instant even on D1), one wakeOn/broadcast\nconnection. Versions still live in the shared store (D1, backend). Costs\nDurable Object active time while tabs watch; opt-in, never detected.\nTABLE - the app owns rsc_versions (rsc-kit never creates/alters it at\nruntime): name TEXT PRIMARY KEY (MySQL: VARCHAR(255)), version BIGINT NOT\nNULL. Extra columns fine if they have defaults. Drizzle: a pgTable in your\nschema + createVersions with the query builder (not raw SQL).\nPOOLERS - a pooler in TRANSACTION mode (PgBouncer; DigitalOcean/Supabase/Neon\npooled URLs) silently drops LISTEN. postgresVersions probes once as it starts\nlistening; if deaf it logs why and reads every second. Fix:\npostgresVersions(pool, { listenWith: directOrSessionModeClient }) - one extra\nconnection per process; reads/writes stay on the pool. (await connection() is\nunrelated: it is about rendering per request.)\nPOLL ON PURPOSE - postgresVersions(pool, { listen: false }) when direct\nconnections are scarce: no listener, no probe, no warning; one indexed read per\nsecond per process for all its tabs; other instances' changes within ~1s.\nWORKERS / SERVERLESS - the stream is stateless (signed names) so it runs where\nresponses stream, but: each isolate/instance watches for its own tabs, so\nin-memory versions are useless - use a shared store. Platform request limits\ncut streams (they reconnect and catch up); billed-per-second functions pay for\nevery watching tab; Hyperdrive/serverless drivers can't LISTEN (probe falls back\nto 1s, or listen: false). No streaming at all: usePolling.\nCLOUDFLARE WORKERS + D1 (verified on wrangler dev; examples/cloudflare /live):\n // src/versions.ts - import the binding INSIDE the query: the build prerenders\n // in Node, runs register(), and cannot load 'cloudflare:workers' (a top-level\n // import breaks the build).\n const db = async () => (await import('cloudflare:workers')).env.DB\n export const versions = () => sqlVersions({ dialect: 'sqlite',\n query: async (t, p) => (await (await db()).prepare(t).bind(...p).all()).results })\n // src/instrumentation.ts: installVersionSource(versions)\n // wrangler.jsonc (Nitro merges it): \"d1_databases\": [{ \"binding\": \"DB\",\n // \"database_name\": \"my-app\", \"database_id\": \"<id>\", \"migrations_dir\": \"migrations\" }]\n // migrations/0001_rsc_versions.sql:\n // CREATE TABLE IF NOT EXISTS rsc_versions (name TEXT PRIMARY KEY, version BIGINT NOT NULL);\n // npx wrangler d1 migrations apply DB --remote (--local for wrangler dev)\n // npx wrangler secret put RSC_SIGNING_SECRET (.dev.vars for wrangler dev)\n // A section reading D1 itself: await connection() first - D1 is per request.\nINSTALL LAZILY - installVersionSource(() => postgresVersions(client)): a\nfactory, made on first use. register() also runs in the build, often without\nDB settings; a store made there makes its client there.\nLISTEN THROUGH AN ORM - pass the driver's listen/notify to createVersions:\nDrizzle on Bun.sql: listen: wake => client.listen('rsc_versions', wake),\nnotify: () => client.notify('rsc_versions', '') with the SQL client you gave\ndrizzle(). node-postgres: a dedicated pg.Client, LISTEN rsc_versions, and\non('notification', wake). A listening store reads the table only as a safety\nnet (~every 5s per server), not every second. NOTIFY inside a transaction is\ndelivered on commit, so it keeps the after-commit rule for you.\nAFTER COMMIT - call changed() AFTER the transaction resolves, never inside\nit: a tab refreshes at once and would read the old data, then sit at the new\nversion. Laravel: DB::afterCommit(fn () => Rsc::changed(...)).\nSAME NAME - many sections/pages may refresh on one name; one changed() moves\nit once and every tab refreshes what it shows. Section names are per page.\nSTARTUP - declare RSC_SIGNING_SECRET in src/env.ts like every other variable,\nrequired in production (z.string().min(32) when NODE_ENV is production): the\ndeploy then fails before taking traffic. No env.ts: assertSigningSecret() from\n@rsc-kit/core/changed in register() does the same. Otherwise rsc-kit refuses\non the first request.\nSECRET - set RSC_SIGNING_SECRET, the same on every instance, with or without\na backend (NOT the host-call secret). In production an app using refreshOn\nrefuses to serve without it. Names are signed per request, never at build.\nREQUEST - a refreshOn function gets params and searchParams ALREADY AWAITED\n(({ params }) => [\\`team:\\${params.team}\\`] is right), and runs per request:\ncookies()/headers() work, e.g. a name for the signed-in user.\nISOLATION - a name is a signal, not data or a permission: a refresh renders\nfor that tab's visitor through their own session and guards, so nobody sees\nanother's data through a name. A tab can only listen for names its page was\nrendered with (signed). Name the data narrowly with ids: conversation:{id},\ninbox:{userId}. No emails/secrets in names. Never shared: true on per-visitor\nsections.\nSHARED - section(name, C, { refreshOn, shared: true }) for a section that\nrenders the same for everyone allowed to see the page: tabs refreshing\nbecause of the same change share ONE render (guards still run per tab).\nNever on per-visitor content - rpc() runs with the first visitor's session.\nLARAVEL - keep the default (versions in the cache; the renderer asks Laravel\n~every 2s, ONE request per renderer process for all its tabs - negligible).\nLaravel stays the only thing talking to its database. RSC_VERSIONS=database\n(publish rsc-migrations) is for pruning or for writers outside Laravel; Laravel\nstill answers. INSTANT: Laravel broadcasting (any Pusher-protocol server:\nReverb, Pusher, Soketi) - RSC_BROADCAST=true in Laravel, RSC_BROADCAST_URL +\nRSC_BROADCAST_KEY for the renderer. Rsc::changed() announces a change (no\nnames - public channel), the renderer asks Laravel at once, else every 30s.\nNever have the renderer read Laravel's database.\nWAKE ANYTHING - wakeOn(listen) from @rsc-kit/core/changed: listenToBroadcast({\nurl, key }), or (wake) => subscriber.subscribe('rsc:changed', wake) for Redis.\nA wake carries nothing; the renderer then asks where versions live. Versions\nare still needed: they say WHICH names moved, per visitor, and let a\nreconnecting tab catch up.\nDEBUG - dev console lists what each region watches; a region missing there\nrendered no names (server log says why). Hidden tabs stop watching and catch\nup when shown.\nCOST - per visible tab one SSE connection (keepalive every 8s - set\nRSC_STREAM_KEEPALIVE_MS lower if a server/proxy drops idle connections sooner;\n~40 bytes\nper change); per change one refresh of just the sections showing the name;\nper server one versions read covering all its tabs.\nHOW - a name has a version, a number that moves when it is said to have\nchanged; nothing else travels. The page learns versions at render, one SSE\nstream per tab (only on a page with names) watches them, and a moved version\ncalls refresh(section) for exactly the regions holding the name. Versions\nlive where the backend keeps shared state (Laravel's cache, a table in Go\nvia reg.Versions(&rsckit.SQLVersions{...}), this process without a backend). Go\nholds the ask and answers the moment a name moves; Laravel (PHP-FPM) answers\nat once and the renderer asks again ~2s later. Runs on Workers/Vercel: the\nstream is stateless, names are signed by the renderer (a tab may only watch\nnames its page was rendered with).\nFROM NEXT - this replaces revalidateTag, and is not the same thing: Next's\ntags label Data Cache entries and revalidateTag() purges them (the NEXT\nrequest refetches; open tabs learn nothing). rsc-kit has no data cache -\nevery render is fresh - so refreshOn names what is on screen and changed()\nrefreshes every open tab now. Port: tags: ['posts'] on the fetch ->\nrefreshOn: ['posts'] on the section; revalidateTag('posts') -> changed('posts').\nWHEN - use names when the change comes from OUTSIDE the tab: webhooks, jobs,\nteammates. Use revalidate('orders') inside the action that made the change.\nUse usePolling for a value with no backend signal; useEvents for a stream of\nvalues (live-data).\nTEST - testChanges() from @rsc-kit/core/testing: host: { ...names.host };\nnames.changed('team:1:repos').\nA name's name is not secret, but what its version reveals is only \"something\nnamed this changed\"; do not put secrets in name names.`,\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. For a backend-side change (webhook, job) see refresh-on',\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(\\`/api/orders/\\${id}/events\\`)\n// latest is typed from the route: what its generator yields. { event: 'paid' }\n// types the named messages. A url no route answers fails the typecheck; another\n// site's stream is unknown until named: useEvents<Message>(url).\n\\`\\`\\`\nevents() frames JSON, sends a keepalive, sets text/event-stream + no-store,\nends the generator on disconnect (signal). EventSource reconnects a dropped\nconnection and resumes with Last-Event-ID when you yielded ids; after an error\nanswer (a 502 mid-deploy) the hook opens a new one, backing off 1s to 30s, and\nat once when the browser is back online. close() stops it.\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(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(url, { onMessage: setOrder }) // useState\n useEvents(url, { onMessage: (m) => dispatch({ type: 'update', m }) }) // reducer\n useEvents(url, { onMessage: (m) => queryClient.setQueryData(['order', id], m) }) // TanStack\n useEvents(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\nA library written for Next that reads next/headers (flags/next) needs the\nbuild's alias in tests/preload.ts: mock.module('next/headers', () => import('@rsc-kit/core/request')).\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 anything the build is made from is newer than the last build -\nsource, vite.config, package.json, the lockfile, the installed @rsc-kit/core -\nso an upgrade is picked up. This is where a guard that never ran or a 404 that\ncame back 200 shows up.\n\nTo assert something is NOT on a page, use app.markup(path), not fetch(): the\ndocument streams the page's payload in <script>s, which holds every prop a\nclient component was given, shown or not. markup() is the document with every\n<script> removed. Do NOT strip scripts by hand.\n\n**A Go or Laravel backend, answered in the test (nothing running):**\n\n\\`\\`\\`ts\nimport { createTestApp, hostReply, HOST_MIDDLEWARE } from '@rsc-kit/core/testing'\nconst app = await createTestApp({\n host: {\n 'Apps.list': ({ args, headers }) => [{ id: 1 }], // rpc('Apps.list', ...)\n 'Apps.create': () => hostReply.invalid({ name: ['Taken.'] }),\n [HOST_MIDDLEWARE]: ({ args, headers }) => // middleware.ts guards; args[0] = names\n headers.get('cookie')?.includes('session=') ? true : hostReply.unauthenticated(),\n },\n})\n\\`\\`\\`\n\nThe host handlers are TYPED from rsc-host.json (interface RscHostFunctions in\n.rsc-kit/rsc-env.d.ts): only functions the backend has, typed args, and the\nfunction's result type or a hostReply answer - never a raw { status: 401 } or a\nmade-up shape. Refusals ALWAYS through hostReply, which passes the same\nconformance suite the real adapters do. hostReply.fail(message) = an unexpected\nerror. Answers go through the real host-call client, so they behave as the backend's\nwould: hostReply.unauthenticated() -> 401, unauthorized() -> 403,\nredirect(to), refuse(status, message), invalid(errors) -> validationErrors,\nrevalidating(result, ...regions). A name with no handler fails the call,\nnaming it. RSC_BACKEND in .env is not called.\n\nA url the app forwards to the backend (a Go route, /login) is a 502 in a test\nunless createTestApp({ backend: (request) => new Response(...) }) answers it;\nit gets the request as the backend would.\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; read with error() and 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.\n\nONE WAY TO READ A FORM - two words, the same everywhere:\n error('title') the field's error, or undefined. !!error('title') is invalid.\n Nested fields are paths: error('address.city'), error('items[0].name').\n The names are TYPED from the action's input (a stub's typed\n parameter, or createActionClient().input(schema)), so\n error('titel') fails tsc: Field names. Open when the action\n says nothing (a FormData function, a url, a cast). A component\n below the form types its prop with FieldNamesOf<typeof action>\n from '@rsc-kit/core/form'. A WRAPPER you write around a stub\n (a function of a FormData) declares its fields on its\n parameter: (form: FormFields<'id' | 'name'>) - then error()\n is closed to them. A TEAM-SCOPED stub func(ctx, team, in) is a\n form's action as stub.bind(null, team): the form fills the\n struct after the bound args, and the names stay - but bind\n into a const first, inline bind() loses them. A stub whose\n parameters are plain strings has no struct for a form to fill:\n wrap it. A form that posts NOTHING (values in React state)\n names its fields on the form: <Form fields={['size']}>.\n formError the refusal NOT about a field (serverError - a 402/409 - or a\n form-level validation message), or undefined. Render it as\n {formError && <p role=\"alert\">{formError}</p>}; do not keep it\n from onError in your own state.\n field('title') a binding to spread - ONLY for a field whose value the UI must\n hold (a character count, a live preview, a control with no\n native element). Everything else stays uncontrolled.\nBeside them: pending, dirty, succeeded, recentlySucceeded, reset(), clearErrors(),\nand data (what is being submitted).\n\nThere is nothing else. No errors map, no fieldState, no useFormValues, no\nuseFormStatus - those were removed; if you see them in old code, convert them.\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, error }) => (\n <>\n <input name=\"title\" />\n {error('title') && <p>{error('title')}</p>}\n <button disabled={pending}>Save</button>\n </>\n )}\n </Form>\n )\n}\n\\`\\`\\`\n\nIn a child component, useForm() returns EXACTLY what the render prop gets, so\nthe code is the same:\n\n\\`\\`\\`tsx\nimport { useForm } from '@rsc-kit/core/form'\n\nfunction SubmitButton() {\n const { pending } = useForm()\n return <button disabled={pending}>Save</button>\n}\n\\`\\`\\`\n\n(Not React's useFormStatus - a different hook with a different answer.)\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\nA field is checked when it is LEFT, not as it is typed (the form listens for\nfocusout, so uncontrolled fields are covered), and on submit. So error() has\nnothing for a field nobody reached - there is no \"touched\" to check first.\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 error() takes the same path.\n\nBinding a field - a rich editor, a Radix select, a value read as it is typed:\n\n\\`\\`\\`tsx\n<Form action={save} defaultValues={{ body: '' }}>\n {({ field, error }) => (\n <>\n <Editor {...field('body')} />\n <span>{field('body').value.length}/100</span>\n {error('body') && <p>{error('body')}</p>}\n </>\n )}\n</Form>\n\\`\\`\\`\n\nfield() gives { name, value, onChange, onBlur } - react-hook-form Controller's\nfour. onChange takes a DOM event OR a bare value. A bound field is still an\nordinary named input, so it arrives in FormData with the rest.\n\nshadcn Field components, with the two words:\n\n\\`\\`\\`tsx\n<Field data-invalid={!!error('title')}>\n <FieldLabel htmlFor=\"title\">Title</FieldLabel>\n <Input id=\"title\" name=\"title\" aria-invalid={!!error('title')} />\n <FieldError>{error('title')}</FieldError>\n</Field>\n\\`\\`\\`\n\nFieldError renders its children and renders nothing when there are none.\n\nA bound field re-renders the form. For a LARGE controlled form, put the field in\nits own component and use useField - the same two words for one name, and it\nre-renders that component and nothing else:\n\n\\`\\`\\`tsx\nfunction Title() {\n const { field, error } = useField('title')\n return <Input {...field} aria-invalid={!!error} />\n}\n\\`\\`\\`\n\nA preview or summary reads a bound value the same way:\n\\`const { field } = useField('title'); field.value\\`. Only BOUND values can be\nread - an uncontrolled input's value is the DOM's.\n\nFor something that is NOT a descendant - a top bar - create the store above both\nand hand it in; useField(name, store) reads it:\n\n\\`\\`\\`tsx\nconst store = useFormStore({ title: '' })\n\n<TopBar store={store} /> // outside the form: useField('title', store)\n<Form action={save} store={store}>…</Form>\n\\`\\`\\`\n\nCreating the store does not subscribe to it, so the holder does not re-render\nper keystroke.\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\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; a refusal shows on its\nfields through error() even with no javascript.\n\n**components.json needs \"rsc\": true.** shadcn's init sees a plain Vite\nproject and writes false, and then a dialog or dropdown it adds has no\n\"use client\". create-rsc-kit --shadcn and rsc-kit init --shadcn set it; after\na bare \\`shadcn init\\`, set it by hand. \\`import { cn } from \"cn\"\\` in its\ncomponents is correct: cn is shadcn's own package (lib/utils re-exports it).\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.\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\nA SEARCH ON THE PAGE IT IS ON: a GET form (action is a url string) whose action\nis the page the visitor is already on keeps that page on screen while the query\nruns - no shell is shown first, the old results stay, an input keeps what was\ntyped, and Back returns to the query it left. pending is true until the new\nresults are on screen:\n\n\\`\\`\\`tsx\n<Form action=\"/lookup\" method=\"get\">\n {({ pending }) => <><input name=\"q\" /><button>{pending ? 'Searching…' : 'Search'}</button></>}\n</Form>\n\\`\\`\\`\n\nTo show a skeleton where the results are instead of the old results, style them\nfrom the form's own data-pending attribute (the results stay OUTSIDE the form, no\nclient wrapper): section:has(form[data-pending]) #results { display: none }, and\nthe same selector showing a #results-skeleton.\n\nDo NOT patch around a skeleton on an in-page search with your own useState,\nrouter.replace or a Suspense key: the form already does this. It is the form\nonly - a <Link> or visit() to the same path with another query is a page of its\nown. A search to ANOTHER page still shows that page's shell first.`,\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 - and a url the backend owns is one once it is\nnamed in backendPaths. Cast with \\`as Route\\` only when the destination is\ngenuinely 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\nThe client components a prefetched page renders are loaded (code only) as its\npayload lands - the first eight per page, bounded per document - so the first\nvisit to a page does not wait on a chunk after the page arrived. Nothing to\nconfigure. Serve over HTTP/2 (Cloudflare does by default): every client\ncomponent is its own small chunk.`,\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'. Same rule: return fieldErrors(...). <Form>\nshows the fields as error('name') and the \"\" key as formError, and useAction\nreturns them as validationErrors; AWAITED DIRECTLY it rejects with\nServerValidationError (@rsc-kit/core/errors: .errors, .fieldErrors, .formErrors)\n- as does a backend stub whose input was refused - so code after the await does\nnot take a refused input for its result. One rule for a stub: anything that did\nnot happen REJECTS (ActionRefusedError, ServerValidationError); only a redirect\nresolves ({ redirected }), and isRedirected is the one check on a resolved value.\n\nWhen the INPUT is fine and the answer is still no - a project with orders\nattached, a plan without the feature - that is a refusal, not a field error.\nUse refuse(message, data), and declare the data's shape with .refusal(schema):\n\n\\`\\`\\`ts\n.refusal(z.object({ blockers: z.array(z.object({ id: z.number(), href: z.string() })) }))\n.handler(async ({ input, refuse }) => {\n if (attached.length) return refuse('Still in use', { blockers: attached.map(toLink) })\n})\n\\`\\`\\`\n\nThe message is result.serverError (a form's formError) and is never replaced\nby onError's generic message. The data is result.refusal, typed from the\nschema and checked against it (a form's formRefusal, typed from the form's\naction - no cast; a component below the form uses useForm<Values, Refusal>(). Do NOT encode blockers into the message or into\nfieldErrors({ '': ... }) - the links are lost. Do NOT re-derive them from the\npage's own list - a refusal decided at the moment of the write knows what is\nblocking it now. A backend refuses the same way (Laravel Rsc::refuse(), Go\nRefuseWith) and arrives as the same result.refusal. Data from an action with\nno .refusal(schema) is not sent. Through a generated stub (<Form\naction={ordersDelete}>) only the MESSAGE arrives, as formError: a plain\nfunction has no .refusal(schema), so the data is left out and the log says so -\nto act on it, call rpc() from a createActionClient action with .refusal(schema).\nAwaited DIRECTLY, a stub REJECTS with ActionRefusedError (@rsc-kit/core/errors:\n.message, .status), so code after the await does not run for a refused write -\nonly a redirect resolves. <Form> shows it as formError, useAction as serverError.\nA query built with .input(schema) takes that schema's input (a wrong key fails\ntsc); a query cannot redirect - throw ServerAuthenticationError (401) instead. Any backend refusal with a status, data or\nnot (Go Refuse(503, \"busy\"), Laravel abort(429)), and a backend's 401, 403 or\n404, arrive as result.serverError with the backend's message - never onError's.\nDo NOT write an onError that unwraps those; only a real failure reaches it. A\n.query() read rejects with the same message, and fetchQuery's error carries\n.status and .refusal.\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\nThe built action is typed by its schema: createPost({ title: 1 }) fails the\ntypecheck; FormData is accepted beside it (how <Form> calls it). It always\nresolves to an object - data | validationErrors | serverError | redirected.\n\nFrom a button, not a form - useAction (next-safe-action's useAction, and its\nuseOptimisticAction through React's useOptimistic):\n\n 'use client'\n import { useAction } from '@rsc-kit/core/useAction'\n const [shown, hide] = useOptimistic(posts, (list, id: number) => list.filter((p) => p.id !== id))\n const { execute, executeAsync, isPending, status, result, reset } = useAction(archivePost, {\n optimistic: ({ id }) => hide(id), // inside the transition; React reverts it on failure\n onSuccess: (data) => …, // typed from the handler\n onError: (message, result) => toast.error(message), // serverError, or the first validation message\n onSettled: (result) => …,\n })\n <button disabled={isPending} onClick={() => execute({ id })}>\n\nexecute never throws (a thrown failure becomes serverError); a redirect runs no\ncallback and sets no state; execute keeps one identity and reads the newest\ncallbacks.\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\nTHE DEFAULT PATTERN - write pages this way: the page is a SYNCHRONOUS\ncomponent; fixed copy, titles and frames go straight in it (they are the\nstored shell); each read is its own async \"slot\" component under its own\n<Suspense>, with a skeleton the shape of what it replaces:\n\n\\`\\`\\`tsx\nexport default function ShopifyPage() { // not async\n return (\n <Card>\n <CardTitle>Shopify</CardTitle> {/* in the shell */}\n <Suspense><ConnectButtonSlot /></Suspense> {/* nothing until known */}\n <Suspense fallback={<ConnectionsSkeleton rows={1} />}>\n <ConnectionSlot /> {/* async: its own read + check */}\n </Suspense>\n </Card>\n )\n}\n\\`\\`\\`\n\nEach slot does its own read and its own permission check, so a fast part is\nnot held back by a slow one, and a slot can move or be revalidated as a\nsection on its own.\n\nloading.tsx wraps the WHOLE page in one boundary. Use it only for a page that\nis one read and nothing else (a detail page whose skeleton is the page). Do\nNOT reach for it by default:\n- the heading/copy/frames the page already knows wait with the data;\n- the slowest read holds back every other part;\n- notFound()/redirect() or a backend 404 decided under it may already have a\n 200 on the wire for a person (the page then ends with a noindex tag; a\n crawler is answered with the finished page and gets the real 404) - a\n check that must be a 404 for everyone goes in middleware.ts, which runs\n before anything is sent;\n- it sits BELOW its layout: a layout that awaits (params, session) needs its\n own <Suspense> inside the layout;\n- a ROOT loading.tsx hides pages waiting in the wrong place (the build only\n warns \"nothing painted without the root loading.tsx\").\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\nFix that by moving the read into a slot under <Suspense>, not by adding a\nloading.tsx.\n\nA painted fallback stays up at least 300 ms - React's rule: content that\narrives within 300 ms of the fallback painting waits until then. A stored\nshell's holes arrive a moment after the shell, so fast data shows its\nskeleton for the full 300 ms (a flash on every app launch). So: no boundary\naround data that is always fast; fallbacks shaped like the content (same\ncard, same size, numbers blank); no animate-pulse on short-lived fallbacks.\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 status stays 200 for a person - a late notFound() ends the page\nwith <meta name=\"robots\" content=\"noindex\"> - while a search engine or\nlink-preview crawler (matched by user agent) is answered once the whole page\nhas rendered and gets the real 404 or redirect. For a person the redirect\ntravels in the row's error digest and the browser\nperforms it as a navigation, layouts kept. A destination that is not this app's\nis a DOCUMENT LOAD, wherever the redirect is called: redirect('https://other.site/..')\ntype-checks as written, and so does a url the backend owns once it is named in\nbackendPaths (see the backend recipe) - no \\`as Route\\`. The url that redirected\nis replaced in history either way, so Back does not return to a page that sends\nyou out again. A late notFound() is the same: the browser asks for not-found.tsx and shows it where the page was, url unchanged.\nA not-found.tsx beside a layout answers notFound() from every page under it,\ninside that layout - the nearest one above the page wins, like error.tsx, so a\nsection keeps its header on a 404 (app/(shop)/not-found.tsx under\napp/(shop)/layout.tsx). A url no route owns is answered by the ROOT one only.\nAn error.tsx on the route never sees either - a redirect or a missing page is\nthe page's answer, not a failure, and nothing should check isNotFoundDigest -\nand 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 NEW app: bun create rsc-kit@latest my-app --pwa. It turns on offline and\nwrites src/app/manifest.ts, a static src/app/offline/page.tsx and starter icons\n(icon-192.png, icon-512.png, icon-maskable-512.png, apple-icon.png) - Chrome\nreports it installable with nothing missing. Replace the icons, keep the names.\nNothing in rsc-kit processes images.\n\nAn EXISTING app - 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 icon-maskable-512.png manifest only, purpose \"maskable\" (safe-zone padded)\n apple-icon.png <link rel=\"apple-touch-icon\">\n apple-splash-1179x2556.png iOS launch screen for that device, portrait\n opengraph-image.png <meta property=\"og:image\">\n twitter-image.png <meta name=\"twitter:image\">\n\n screenshot-wide-1280x720.png manifest screenshots, form_factor wide (desktop)\n screenshot-narrow-750x1334.png form_factor narrow (phone) - Chrome's richer install sheet\n\nSizes are read from the filename; no image is ever opened or resized.\n\nSet id: '/' in the manifest and never change it - without it the app's identity\nis its startUrl, and changing that orphans every install (the build warns).\nscope, orientation, categories and shortcuts are typed too.\n\nAn install button: useInstall() from '@rsc-kit/core/install' ->\n{ canInstall, install, installed, ios }. The offer (beforeinstallprompt) is\ncaught by the page's inline bootstrap, so a button that mounts late still gets\nit - do NOT add your own beforeinstallprompt listener in an effect, it can miss\nthe event. install() resolves 'accepted' | 'dismissed' | 'unavailable'. On iOS\nthere is no offer; when ios is true, tell the visitor Share -> Add to Home Screen.\n\n**Splash screens.** Android draws its own from the manifest: name, the 512px\nicon and backgroundColor - set all three. iOS needs one image per device and\norientation: name each apple-splash-WIDTHxHEIGHT.png at the device's pixel\nsize (landscape swaps them) and the build writes the media query from a device\ntable; a size no device has is named at build and linked nowhere. A Next port\ncan keep metadata.appleWebApp ({ capable, title, statusBarStyle, startupImage })\nunchanged. read_guide({ slug: 'pwa' }) has the size table.\n\n**One grey frame on a dark launch (iOS).** In dark mode an installed app can\nshow one frame of grey (~#2C2C2E) between the splash and the page: iOS starts\ndismissing the splash a frame before the page paints, and the empty web view\nshows through. Nothing in the page reaches it - not color-scheme, an html\nbackground, cards or placeholders. It is 1/60 s; do not chase it.\n\nThe 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. So does every other stored page, shells\nincluded; one that hydrates keeps its link as media=\"print\" so React finds\nit rather than fetching it again. Do not \"fix\" that link - it is not render\nblocking, and removing it makes React download the sheet after hydration.\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\nThe engine's server-only entries (request, redirect, revalidate, cache,\nsection, host, ...) are REFUSED in the browser bundle: a build or a dev load\nfails with \"'@rsc-kit/core/redirect' is for the server, and ended up in the\nbrowser bundle\" and the import chain. The cause is a helper file both a page\nand a client component import - split it. Mark your own server-only files with\nimport 'server-only' (the build honours it).\n\nA page never answers a request the browser marks as an image, script,\nstylesheet or font (Sec-Fetch-Dest): it gets a plain 404 before any middleware,\nlayout or backend call runs, so a dynamic /[team] route does not run its guard\nfor /favicon.ico. A route.ts still answers such a request - an image endpoint\nis a route, not a page - and a navigation or fetch() gets the page as always.\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\nA page's OWN share image is a file beside it: src/app/pricing/opengraph-image.png\n(and twitter-image.png) is the card for /pricing and the pages beneath it, the\nNEAREST folder winning - a route group like (marketing) counts as a folder, and\n[slug] covers every page it answers. Do not add a per-page tag to get this. A page\nthat exports openGraph.images replaces the site's image rather than adding a\nsecond og:image behind it; the nearer of the file and the export wins, and in the\nsame folder the file does. An openGraph with no images leaves the card to the file.\n_private, @slot and (.)intercepted folders are not searched.\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 never holds up a page: it streams under a boundary of its\nown, so the shell goes out at once and the <title>/<meta> arrive when the\nlookup finishes. A stored pattern shell has a hole where they go and the\nrequest fills it with the real values. A crawler is answered with the\nfinished page, metadata in <head>. Static \\`metadata\\` alone is in the first\nbytes. Nothing to do in the app - do not move a lookup out of\ngenerateMetadata to \"speed it up\".`,\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\nA generated backend stub (Go/Laravel actions in server-actions.generated.ts) is\ntyped Promise<T | Redirected>, not an ActionResult: the redirect answer is\n{ redirected } in place of T. Narrow with isRedirected from\n'@rsc-kit/core/errors' before reading it, and also before any \"success\" that\nfollows an await of a void stub:\n const secret = await databasesReveal(id)\n if (isRedirected(secret)) return\nReading secret.Password first is a type error on purpose. A <Form> or\nuseAction already skips onSuccess on a redirect.\n\ntsc does NOT see a redirect read as text: a template literal around a\nstring | Redirected compiles and goes to /t/[object Object]. The type-aware oxlint rules\ntypescript/restrict-template-expressions, no-base-to-string and\nrestrict-plus-operands do (a create-rsc-kit --lint project has them; run\noxlint src --type-aware, with oxlint-tsgolint installed). Nothing flags\n\"await stub(); toast.success()\" - check isRedirected there, and test a click:\nmount the component with happy-dom + react-dom/client + act, replace the stubs\nmodule (mock.module / vi.mock), and assert the redirected case shows no\nsuccess. Register happy-dom IN THAT TEST FILE and GlobalRegistrator.unregister()\nafter it, from a module the file imports FIRST (import './dom'), not a beforeAll:\na library checks for a DOM when it is imported (Base UI picks its layout effect\nfrom typeof document once), so a component imported earlier never opens its\ndialogs. Never in a global preload: it replaces Request/Headers/fetch, drops\nthe Cookie header, and createTestApp then refuses to run. Run bun test\n--isolate. See the testing guide, \"Components\".\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. bun test is\n not built: tests/preload.ts needs\n mock.module('next/headers', () => import('@rsc-kit/core/request'))\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_* -> VITE_* 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 useAction / useOptimisticAction -> useAction from @rsc-kit/core/useAction\n (optimistic: option + React's useOptimistic)\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, VITE_ 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 in the app, no shim to write.\n\nbun test is not built, so the alias is not there: flags/next finds Next's own\nnext/headers (a monorepo often has one) and throws \"headers was called\noutside a request scope\". One line in tests/preload.ts (the scaffold ships it):\n\n mock.module('next/headers', () => import('@rsc-kit/core/request'))\n\nThen a flag evaluates inside withRequest(), as in the app.\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\". A layout gets params too (its own segments' and those above, as a promise),\nand is rendered again when its own value changes. A route listing no urls\nstores one shell for all values, so a layout awaiting params above every\nboundary fails the build: read them under <Suspense> in the layout, or list\nthe urls with generateStaticParams. A tenant check that should 404 goes in\n[domain]/middleware.ts - its argument is the params, and notFound() there is\na real 404. 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 - the\nproject's first package.json if it has none - and .env (RSC_BACKEND + a\ngenerated secret) and prints the Go wiring; go get github.com/rsc-kit/go.\nA new app: bun create rsc-kit --backend=<url>, or rsc-kit init in an empty\ndirectory, which starts one there.\n\nThe backend writes rsc-host.json at the project root (beside vite.config.ts):\n{ \"actions\": { jsName: \"Function.name\" }, \"functions\": [\"Function.name\", ...] }.\nactions become \"use server\" stubs in src/server-actions.generated.ts;\nfunctions type rpc()'s first argument, so a misspelt name fails tsc. rpc is a\nGLOBAL the build declares in .rsc-kit/rsc-env.d.ts - never import it. Have\nVite write the manifest so it cannot go stale:\nrscKit({ hostManifest: { command: ['go', 'run', '.', '-manifest', '../rsc-host.json'], cwd: 'backend', watch: ['backend'] } })\n(watch: rerun when the backend's source changes under dev, so new functions are typed\nwithout a restart). Committed rsc-host.json/types? CI: rsc-kit-typegen --check fails\nwhen they are stale.\n(Go: reg.WriteManifest(path); Laravel: php artisan rsc:host-manifest).\nGo TYPED functions - prefer these: reg.Handle(\"Orders.recent\", func(ctx context.Context, limit int) ([]Order, error) {...})\nand reg.HandleAction(jsName, name, fn). Any positional params (structs, slices,\nmaps...), a trailing pointer is optional, variadic takes the rest. Their types\ngo into rsc-host.json, so rpc('Orders.recent', 5) is Order[] with no <T>, and\nthe action stub is typed and also accepts a FormData (fields -> the struct param:\nthe first, or the one after args bound with stub.bind(null, team)).\nA typed Go function's nil slice/map is sent as []/{} (only a nil pointer is null).\nA backend FAILURE in development carries its trace: the thrown error's cause is\n\"Backend <Type>\" with the PHP/Go frames, and the stack says \"Caused in the backend\nby ...\" - read that before guessing (Laravel: app.debug; Go: CallbackHandler.Debug\nor RSC_DEBUG=1). Never in production.\nA failed rpc() in a server component: catch and check\nerror instanceof ServerAuthenticationError (401) / ServerAuthorizationError (403)\nfrom '@rsc-kit/core/errors'; rethrow anything else.\nreg.Register(name, func(ctx, args rsckit.Args) (any, error)) stays untyped.\nLaravel: rsc:host-manifest types each app/Rsc method from its PHP signature -\nint/float/string/bool params (nullable, defaults optional, variadic), a\nFormRequest first param from its rules(), and a result that is a scalar, a\nbacked enum, or a plain class with public typed properties (a data object ->\nan interface). array/Model/Collection/JsonSerializable stay unknown: give\nthose rpc<T>(). Return a data object, not an array, to get a typed result. Tests\nanswer the backend with createTestApp({ host }) - how_to testing.\n\nWriting or changing an ADAPTER: it must pass the conformance suite before it\nships - register the Conformance.* functions (read_guide your-own-backend,\n\"Before you ship\"), serve them, and run\nnpx -y -p @rsc-kit/core rsc-kit-conformance --endpoint <url>/__rsc/host-call --secret <s> --manifest rsc-host.json\n(Go: cmd/conformance; Laravel: tests/Conformance are fixtures to copy).\nBAP vs Inertia for a Laravel team: read_guide bap-vs-inertia.\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\nA url the route tree does not own goes to the backend (/login, a webhook, an\nOAuth callback). One that it DOES match does not: a dynamic root route such as\n[team]/[app] matches every two-segment url, renders, says notFound() - and a page\nthat said that is never forwarded, so the backend's /gitlab/connect was the\napp's 404 (and sign-in looped) - Laravel's /login and /auth/* as much as Go's\nwhen the renderer is in front. Name the backend's prefixes, and they are\nforwarded BEFORE any page is asked: rscKit({ backendPaths: ['/auth', '/github',\n'/gitlab'] }). A prefix at a segment boundary (/auth, not /authors), every\nmethod; '/' and /_rsc are refused; a team called auth is then unreachable. Do NOT\nwrite catch-all route.ts files that forward by hand: that copies the forwarding.\nA named prefix is also a DOCUMENT LOAD in the browser, never a page: a Link,\nvisit() or redirect('/gitlab/connect') to it loads the document and is\nnever prefetched, so the backend route may bounce on to another site (an OAuth\nstart). NOT named, the router fetches it as a payload, the bounce to another\norigin is a hop a fetch cannot make, and the tap does nothing. Do NOT work around\nit with a client component that sets window.location.\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, or the status the backend gave: abort(429, 'Slow down')\nkeeps its 429 and its message), 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. An\naction shows an abort()'s message to the visitor as written, so give it one -\nabort(429, 'Slow down'); without one the form says \"Refused.\". Only a failure\nis replaced by onError's generic message.\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 manifest\n(rsc-host.json) too - a running dev server restarts on its own when it\nchanges, 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:host-manifest\\` writes rsc-host.json - { actions,\nfunctions } - and vite.config.ts runs it as dev and every build start:\nrscKit({ hostManifest: { command: ['php', 'artisan', 'rsc:host-manifest'] } }).\nfunctions types rpc()'s first argument, so a misspelt name fails tsc. The\nbuild writes the \"use server\" stubs from actions - 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. Batches are\nrequired: any other answer fails every call in it (at most 50 per batch;\n413 for more is fine).\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 and teardown - src/instrumentation.ts is imported before any page, its register() awaited before the first request, its shutdown() called when the server stops',\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 export async function shutdown() { // optional; called when the server stops\n await db.end()\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\nStopping is automatic: on SIGTERM/SIGINT a built server drains in-flight\nrequests (SERVER_SHUTDOWN_TIMEOUT, 5s), calls shutdown() (RSC_SHUTDOWN_TIMEOUT,\n10s; a hang or throw does not hold it) and exits 0. Do NOT add a SIGTERM\nhandler that calls process.exit() - it runs before the drain and drops\nin-flight requests. A logger flushes on 'exit', never on a signal.\n\nA file in src/commands (how_to commands) imports this one too, so the env is\nvalidated for it; its register() runs only for a command that exports\n\\`register = true\\`.\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: 'commands',\n summary: 'A release step or a worker is the same app started differently - src/commands/<name>.ts run as ./app migrate, or node .output/server/commands.mjs migrate',\n body: `Migrations before a deploy, a queue worker, a one-off script: a file in\nsrc/commands, with a default export. It shares the pages' bundle and\ndependencies (the database package, the logger), so there is no second build and\nno second binary.\n\n // src/commands/migrate.ts\n import { db } from '@repo/database'\n export const description = 'Apply the database migrations' // plain string; \\`--help\\` lists it\n export const register = true // opt in to instrumentation.ts's register()\n export default async function (args: string[]) {\n await db.migrate({ dryRun: args.includes('--dry-run') })\n }\n\n ./app migrate --dry-run # the compiled binary\n node .output/server/commands.mjs migrate --dry-run # a plain build (bun works too)\n ./app --help # lists them\n ./app # no arguments: the server, as ever\n\nThe return value is the exit code: nothing is 0, a number 0-255 is itself, a\nthrow is 1 with the error, any other return (a string, 256) is 1 and says so.\nAn unknown name exits 2. Return the code - do NOT process.exit() inside a\ncommand, it skips shutdown(). args is everything after the name, untouched:\n./app migrate --help gives migrate the --help.\n\nA command is a file DIRECTLY in src/commands; the name is the file's name. \\`*.d.ts\\`,\n\\`*.test.ts\\`, \\`*.spec.ts\\` and names starting with _ or . are not commands (_shared.ts\nis how two share a helper). A folder is not one: db/seed.ts is not db:seed, and\nthe build says so.\n\n./app --help and an unknown name are answered before the app loads (from a list in\nthe entry), so they need no valid environment. A real command loads the app:\nenv is validated (instrumentation.ts is imported, so \\`import './env'\\` refuses a\nbad environment) but register() does NOT run unless the command exports\n\\`register = true\\` - a migration should not fail on a queue it never touches. A\ncommand that opted in gets shutdown() after it returns, also after it throws.\n\nA worker is a command that waits. Its second argument is the context,\n{ signal, deadline } (the type is CommandContext, from '@rsc-kit/core/commands',\nwhich also has defineCommand to type a command without annotating it). signal\nis aborted by SIGTERM/SIGINT. Return when it aborts and the process runs\nshutdown() and exits 0; RSC_SHUTDOWN_TIMEOUT (10s) bounds it, a second signal\ndoes not wait. deadline is when the runner stops waiting (a Date.now() time),\nundefined until told to stop - budget the current job against\ncontext.deadline - Date.now(), do not re-read RSC_SHUTDOWN_TIMEOUT. deadline\nis a GETTER: do NOT destructure it ((args, { signal, deadline }) reads it once,\nat the start, when it is always undefined, and keeps that); read context.deadline\nwhen you need it. signal is a plain property and can be destructured.\n\nA stop reaches a command one of two ways, so a command that waits needs BOTH\nthe check and the listener. BEFORE it starts (while the app loads, tens of ms, or\nduring register()) the signal is ALREADY aborted and its 'abort' event has fired -\nthe runner gives the event loop one turn before calling the command, so a waiting\nstop is delivered first (the same on Node, Bun and a binary). AFTER it starts\n(the usual case) signal.aborted turns true and 'abort' fires while it runs. So:\ncheck signal.aborted as the command starts, and listen for 'abort' - or pass\nsignal to something that does ({ signal } to a queue, sleep(ms, undefined,\n{ signal })). Checking alone misses a stop during a long await; listening alone\nwaits for ever on a stop that came first. The loop below does both.\n\n import { defineCommand } from '@rsc-kit/core/commands'\n\n export default defineCommand(async (_args, { signal }) => {\n while (!signal.aborted) await queue.processNext({ signal })\n })\n\nThe entry holds SIGTERM/SIGINT from its first line, however long the app takes\nto load. In a compiled binary Bun first parses every module in the file (about\n170 ms for 580 modules and 119 MB), and a stop in that time is the default\naction: exit 143. --splitting makes the entry its own chunk, and the scaffold's\ncompile script has it: bun build --compile --splitting\n.output/server/compile.mjs --outfile dist/app. An app made before it keeps its\nold script until the flag is added; Bun 1.2 refuses it (\"cannot use --compile\nwith --splitting\"). The same stop 250 ms into an 80 MB\nbinary exits 143 without it and 0 with it. A plain build has no such window.\n--sourcemap with --compile embeds the map and ALSO writes .map files beside the\nbinary (one per chunk with --splitting: hundreds for a large app, dist/ in the\nhundreds of MB). The binary does not read them - a stack trace is the same with\nthem deleted - so ship the binary alone and rm -f dist/*.map, or leave\n--sourcemap off (the scaffold does).\n\nDo NOT run \\`node .output/server/index.mjs migrate\\`: index.mjs is the server and\nignores its arguments, so that starts the server. Commands are for the bun and\nnode presets only. On a Worker the build leaves src/commands out and says so\n(run the file in CI, or wrangler for D1); in \\`vite dev\\` run the file itself\n(\\`bun src/commands/migrate.ts\\`). The build lists them in .output/rsc-kit.json\nas \\`commands\\`.\n\nA scheduler is a command that keeps running (like Laravel's schedule:work): a\n\\`while (!signal.aborted)\\` loop that runs what is due and then\n\\`await sleep(60_000, undefined, { signal }).catch(() => {})\\` (sleep is\nsetTimeout from node:timers/promises), started as \\`./app schedule\\`. Run ONE\ninstance - two run every job twice, which is why a schedule does not belong in\nthe web server, which runs as many copies as it is scaled to. A job that runs\nonce a day is an ordinary command started by the platform's cron.\n\nFull guide: read_guide({ slug: 'commands' }).`,\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). With src/commands the same\nbinary is also ./app migrate (how_to commands). 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\". Use --bytecode --format=esm, which 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 renderer, one route, nothing shipped by the engine:\n // src/app/reference/route.ts (bun add @scalar/client-side-rendering)\n import { renderApiReference } from '@scalar/client-side-rendering'\n export const GET = () => new Response(renderApiReference({ config: { url: '/openapi.json' } }),\n { headers: { 'Content-Type': 'text/html; charset=utf-8' } })\n export const openapi = false\nNOT @scalar/nextjs-api-reference: same call plus a theme, but its peer\ndependency on next makes npm/pnpm/bun install all of Next.js into the app.\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: 'secrets',\n summary: 'The secrets rsc-kit reads - RSC_HOST_CALL_SECRET, RSC_SIGNING_SECRET, RSC_ACTION_ENCRYPTION_KEY - what each protects, how to generate them, where they go in production, and what rotating one does',\n body: `Three, each its own key for its own job. Never reuse one as another.\n\n RSC_HOST_CALL_SECRET renderer <-> Go/Laravel backend calls. Required with a\n backend; read by the renderer AND the backend.\n RSC_SIGNING_SECRET signs refreshOn names a page hands the tab. Required in\n production when any page/section uses refreshOn (the\n server refuses to serve without it). Renderer only.\n Never falls back to the host-call secret.\n RSC_ACTION_ENCRYPTION_KEY optional: pins the key server actions encrypt with, so\n actions survive a deploy. Set at BUILD time too.\n\nGENERATE - once each, a different value each:\n openssl rand -base64 32\n node -e \"console.log(require('crypto').randomBytes(32).toString('base64url'))\"\n php -r \"echo base64_encode(random_bytes(32)), PHP_EOL;\"\nDevelopment: create-rsc-kit / rsc-kit init (with a backend) and\nphp artisan rsc:install write the first two into .env already.\n\nPRODUCTION - in the platform's environment settings, never in git:\n- the SAME value on every instance (a tab served by one renderer is checked by\n another; a call may land on any backend replica);\n- read at runtime, so one build deploys anywhere (except the action key).\n\nROTATING is a deploy:\n- host-call: change renderer and backend together; between them every rpc() is refused.\n- signing: open pages stop refreshing until reloaded; nothing else breaks.\n- action key: actions from pages loaded before the change fail.\n\nDocs: /hosts/deployment#secrets.`,\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: 'VITE_',\n client: { VITE_SITE_URL: z.url() },\n // No bare process.env: import.meta.env carries the VITE_ values, and the\n // file does not throw in the browser if a client component imports it.\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 VITE_ and is read from import.meta.env\n(Vite's own prefix and the only one - a PUBLIC_* variable, once accepted\nbeside it, fails the build by name), which is why runtimeEnv merges both. Commit .env.example, not .env.\n\nCLIENT COMPONENTS DO NOT IMPORT src/env.ts. A \"use client\" file (or a lib file\none imports) reads import.meta.env.VITE_X directly, declared in\nImportMetaEnv. Importing env there ships the validation library to the\nbrowser - measured: 20 kB gzipped of Zod + env-core in a chunk that needed one\nurl. The value is still validated: the server imports env at startup.\n\nNext: NEXT_PUBLIC_* becomes VITE_*; @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: 'refresh-on',\n summary: 'Refresh a page or section when the backend says its data changed - a webhook, a job, another user - with nothing polling: refreshOn on a section + Changed / Rsc::changed / changed(); server-initiated revalidation; where revalidateTag was',\n body: `A section names the data it shows; whatever changes it says so, from\nanywhere; every open tab showing it refreshes. No polling, no stream to\nwrite, no library.\n\\`\\`\\`tsx\n// src/app/t/[team]/repos.section.tsx\nimport { section } from '@rsc-kit/core/section'\nconst ReposSection = section('repos', Repos, { refreshOn: ({ params }) => [\\`team:\\${params.team}:repos\\`] })\nexport default ReposSection // named, so the editor offers it as an import: a bare export default section(...) is offered as reposSection\n// a page: export const refreshOn = ({ params }) => [...] -> a change refreshes the page\n// typed from its schemas: export const refreshOn: PageRefreshOn<typeof params> = ({ params }) => [...]\n// (import type { PageRefreshOn } from '@rsc-kit/core/section'; params arrive awaited + parsed)\n// no schema: PageRefreshOn<'/[team]/[app]/[env]'> types params from the route's pattern;\n// a misspelt param or a pattern that is not a route fails the typecheck\n// export const / function / { refreshOn } / { refreshOn } from './x' all count; export * from does not\n\\`\\`\\`\nA LAYOUT'S REGION - what is the same on every tab (a title, a status, the tab bar)\nbelongs in the layout, which stays mounted while the pages under it change, and\nwhen it shows data that changes it is a section there. A page refreshes the\nsections beside it and then those beside its layouts, nearest first, so\nrevalidate('header') from an action on any tab, or a refreshOn change, renders\njust that. Put it in its own <Suspense> in the layout (it reads params, and a\nlayout that awaits them above every boundary fails the build). Pass\nparams={params} through; a refresh renders the section with the page's props, which hold every segment the layout's do. A name\nis unique along the chain (the nearer one wins). The active tab is usePathname()\nin a client component, not a refresh. Do NOT draw the header in each tab's page\ninside its Suspense: a tab switch mounts a new page, and its fallback replaces\nthe header until the data arrives.\nPARENT AND CHILD - a change to a child changes its parent's list: say both\nwhere the write is - changed(\\`app:\\${id}\\`, \\`team:\\${teamId}:apps\\`) - and let each\nregion name only what it shows. Never make a list watch every child's name.\nNOTHING ANNOUNCES IT (a host's logs, a third-party status) - usePolling, not refreshOn.\nTEST IT - (await createTestApp()).watched('/teams/acme') -> { page: [...], apps: [...] },\nthe names each region is handed; [] means refreshOn gave none (also warned in dev).\nSAY IT CHANGED - wherever the change happens, not only in an action:\n Go: reg.Changed(ctx, \"team:\"+teamID+\":repos\") // the webhook handler, a job\n Laravel: Rsc::changed(\"team:$teamId:repos\"); // a controller, a job, a listener\n JS only: import { changed } from '@rsc-kit/core/changed'; await changed(\\`team:\\${team}:repos\\`)\nANY DATABASE - rsc-kit ships no driver; hand it yours. Postgres:\npostgresVersions(sql) (postgres.js/Bun.sql, or Drizzle's db.$client) - one\nstatement per change, instant via LISTEN. MySQL/MariaDB: sqlVersions({\ndialect: 'mysql', query: async (t, p) => (await pool.query(t, p))[0] })\n(mysql2). SQLite: sqlVersions({ dialect: 'sqlite', query: async (t, p) =>\ndb.query(t).all(...p) }). libSQL/Turso: sqlVersions({ dialect: 'sqlite',\nquery: async (sql, args) => (await client.execute({ sql, args })).rows }).\nAlways pass dialect: without it a change is a read + write per name.\nRedis/ORM query builders: createVersions({ read, bump, listen?, notify? }).\nInstant across instances: postgres.js LISTEN, Redis pub/sub; others within ~1s.\nNO BACKEND (JS only) - changed() works as-is on one server. With more than\none instance, or a worker process that finishes jobs, keep versions in a\ntable they all share: installVersionSource(postgresVersions(sql)) in\ninstrumentation.ts register() AND in the worker (postgres.js LISTENs, so\ncross-instance changes are instant; so does Bun.sql (Bun 1.4+); any SQL\ndriver/ORM: sqlVersions({ query }) - Prisma: query: (t, p) =>\nprisma.$queryRawUnsafe(t, ...p); anything else (Drizzle query builder, Redis,\nKV): createVersions({ read(names), bump(names), listen?, notify? }) and\nrsc-kit does the waiting/waking). An app-installed store always wins over a\nbackend's. In-memory versions fail with 2+ instances; in production rsc-kit logs a\nwarning once when it falls back to them - installVersionSource(memoryVersions())\nsays \"one instance on purpose\" and silences it.\nA DIRECT DATABASE EDIT (psql, a migration, an admin tool, another app) says\nNOTHING: nothing watches tables, so open tabs stay stale until refreshed (a reload\nreads fresh - the data is not wrong). Make the WRITER call changed() after the\ncommit; or put a TRIGGER on the data table that upserts the rsc_versions row in\nthe same transaction (moves with the commit, a rollback moves nothing) and, on\nPostgres, pg_notify('rsc_versions', '') so tabs hear at once - without it up to\n30s with a listening store, 1s without; the name must be spelt as refreshOn\nspells it. usePolling only where nothing can say it.\nTable: rsc_versions(name TEXT PRIMARY KEY, version BIGINT NOT NULL) - same as\nGo's SQLVersions. ANY process can bump without rsc-kit, with one upsert that\nmoves version to GREATEST(version + 1, now in ms) - Postgres:\nINSERT ... VALUES (name, (EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint)\nON CONFLICT (name) DO UPDATE SET version = GREATEST(rsc_versions.version + 1,\nEXCLUDED.version); then NOTIFY rsc_versions. MySQL: GREATEST(version + 1,\nVALUES(version)) with FLOOR(UNIX_TIMESTAMP(NOW(3)) * 1000); SQLite: MAX(...)\nwith CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER).\nCLEANUP - a version is the time a name last changed, so deleting old rows is\nalways safe (a tab holding one refreshes once; it never comes back at a value\na tab holds): versions.prune() on sqlVersions/postgresVersions (30 days), or\nDELETE FROM rsc_versions WHERE version < <ms 30 days ago> from a cron.\nmemoryVersions forgets after 30 days; the server forgets names no tab\nwatches. Go: store.Prune(ctx, 0) on SQLVersions/MemoryVersions. Laravel:\ncache keys expire after RSC_VERSIONS_KEEP_DAYS (30); with\nRSC_VERSIONS=database schedule php artisan rsc:prune-versions daily. A custom store's bump MUST use nextVersion(current) - never + 1, which repeats once a row is pruned.\nGO, SEVERAL INSTANCES - SQLVersions is noticed within ChangedPoll (1s). For\nat once: SQLVersions{..., Notify: \"rsc_versions\"} plus reg.WakeOn(ctx,\nlistener) where the listener (pgx: LISTEN rsc_versions, WaitForNotification)\ncalls connected() then wake() per notification. One instance needs neither.\nWORKERS, ONE HUB - by default each isolate watches its own tabs: one ask per\ntab every 2s. nitro({ preset: 'cloudflare_durable' }) plus wrangler.jsonc\ndurable_objects binding \"$DurableObject\" (class \"$DurableObject\", migration\nnew_sqlite_classes) and vars RSC_CHANGES_HUB: \"$DurableObject\" sends every\ntab's stream to one Durable Object: one ask for all tabs, changed() in any\nisolate wakes it at once (instant even on D1), one wakeOn/broadcast\nconnection. Versions still live in the shared store (D1, backend). Costs\nDurable Object active time while tabs watch; opt-in, never detected.\nTABLE - the app owns rsc_versions (rsc-kit never creates/alters it at\nruntime): name TEXT PRIMARY KEY (MySQL: VARCHAR(255)), version BIGINT NOT\nNULL. Extra columns fine if they have defaults. Drizzle: a pgTable in your\nschema + createVersions with the query builder (not raw SQL).\nPOOLERS - a pooler in TRANSACTION mode (PgBouncer; DigitalOcean/Supabase/Neon\npooled URLs) silently drops LISTEN. postgresVersions probes once as it starts\nlistening; if deaf it logs why and reads every second. Fix:\npostgresVersions(pool, { listenWith: directOrSessionModeClient }) - one extra\nconnection per process; reads/writes stay on the pool. (await connection() is\nunrelated: it is about rendering per request.)\nPOLL ON PURPOSE - postgresVersions(pool, { listen: false }) when direct\nconnections are scarce: no listener, no probe, no warning; one indexed read per\nsecond per process for all its tabs; other instances' changes within ~1s.\nWORKERS / SERVERLESS - the stream is stateless (signed names) so it runs where\nresponses stream, but: each isolate/instance watches for its own tabs, so\nin-memory versions are useless - use a shared store. Platform request limits\ncut streams (they reconnect and catch up); billed-per-second functions pay for\nevery watching tab; Hyperdrive/serverless drivers can't LISTEN (probe falls back\nto 1s, or listen: false). No streaming at all: usePolling.\nCLOUDFLARE WORKERS + D1 (verified on wrangler dev; examples/cloudflare /live):\n // src/versions.ts - import the binding INSIDE the query: the build prerenders\n // in Node, runs register(), and cannot load 'cloudflare:workers' (a top-level\n // import breaks the build).\n const db = async () => (await import('cloudflare:workers')).env.DB\n export const versions = () => sqlVersions({ dialect: 'sqlite',\n query: async (t, p) => (await (await db()).prepare(t).bind(...p).all()).results })\n // src/instrumentation.ts: installVersionSource(versions)\n // wrangler.jsonc (Nitro merges it): \"d1_databases\": [{ \"binding\": \"DB\",\n // \"database_name\": \"my-app\", \"database_id\": \"<id>\", \"migrations_dir\": \"migrations\" }]\n // migrations/0001_rsc_versions.sql:\n // CREATE TABLE IF NOT EXISTS rsc_versions (name TEXT PRIMARY KEY, version BIGINT NOT NULL);\n // npx wrangler d1 migrations apply DB --remote (--local for wrangler dev)\n // npx wrangler secret put RSC_SIGNING_SECRET (.dev.vars for wrangler dev)\n // A section reading D1 itself: await connection() first - D1 is per request.\nINSTALL LAZILY - installVersionSource(() => postgresVersions(client)): a\nfactory, made on first use. register() also runs in the build, often without\nDB settings; a store made there makes its client there.\nLISTEN THROUGH AN ORM - pass the driver's listen/notify to createVersions:\nDrizzle on Bun.sql: listen: wake => client.listen('rsc_versions', wake),\nnotify: () => client.notify('rsc_versions', '') with the SQL client you gave\ndrizzle(). node-postgres: a dedicated pg.Client, LISTEN rsc_versions, and\non('notification', wake). A listening store reads the table only as a safety\nnet (~every 5s per server), not every second. NOTIFY inside a transaction is\ndelivered on commit, so it keeps the after-commit rule for you.\nAFTER COMMIT - call changed() AFTER the transaction resolves, never inside\nit: a tab refreshes at once and would read the old data, then sit at the new\nversion. Laravel: DB::afterCommit(fn () => Rsc::changed(...)).\nSAME NAME - many sections/pages may refresh on one name; one changed() moves\nit once and every tab refreshes what it shows. Section names are per page.\nSTARTUP - declare RSC_SIGNING_SECRET in src/env.ts like every other variable,\nrequired in production (z.string().min(32) when NODE_ENV is production): the\ndeploy then fails before taking traffic. No env.ts: assertSigningSecret() from\n@rsc-kit/core/changed in register() does the same. Otherwise rsc-kit refuses\non the first request.\nSECRET - set RSC_SIGNING_SECRET, the same on every instance, with or without\na backend (NOT the host-call secret). In production an app using refreshOn\nrefuses to serve without it. Names are signed per request, never at build.\nREQUEST - a refreshOn function gets params and searchParams ALREADY AWAITED\n(({ params }) => [\\`team:\\${params.team}\\`] is right), and runs per request:\ncookies()/headers() work, e.g. a name for the signed-in user.\nISOLATION - a name is a signal, not data or a permission: a refresh renders\nfor that tab's visitor through their own session and guards, so nobody sees\nanother's data through a name. A tab can only listen for names its page was\nrendered with (signed). Name the data narrowly with ids: conversation:{id},\ninbox:{userId}. No emails/secrets in names. Never shared: true on per-visitor\nsections.\nSHARED - section(name, C, { refreshOn, shared: true }) for a section that\nrenders the same for everyone allowed to see the page: tabs refreshing\nbecause of the same change share ONE render (guards still run per tab).\nNever on per-visitor content - rpc() runs with the first visitor's session.\nLARAVEL - keep the default (versions in the cache; the renderer asks Laravel\n~every 2s, ONE request per renderer process for all its tabs - negligible).\nLaravel stays the only thing talking to its database. RSC_VERSIONS=database\n(publish rsc-migrations) is for pruning or for writers outside Laravel; Laravel\nstill answers. INSTANT: Laravel broadcasting (any Pusher-protocol server:\nReverb, Pusher, Soketi) - RSC_BROADCAST=true in Laravel, RSC_BROADCAST_URL +\nRSC_BROADCAST_KEY for the renderer. Rsc::changed() announces a change (no\nnames - public channel), the renderer asks Laravel at once, else every 30s.\nNever have the renderer read Laravel's database.\nWAKE ANYTHING - wakeOn(listen) from @rsc-kit/core/changed: listenToBroadcast({\nurl, key }), or (wake) => subscriber.subscribe('rsc:changed', wake) for Redis.\nA wake carries nothing; the renderer then asks where versions live. Versions\nare still needed: they say WHICH names moved, per visitor, and let a\nreconnecting tab catch up.\nDEBUG - dev console lists what each region watches; a region missing there\nrendered no names (server log says why). Hidden tabs stop watching and catch\nup when shown.\nCOST - per visible tab one SSE connection (keepalive every 8s - set\nRSC_STREAM_KEEPALIVE_MS lower if a server/proxy drops idle connections sooner;\n~40 bytes\nper change); per change one refresh of just the sections showing the name;\nper server one versions read covering all its tabs.\nHOW - a name has a version, a number that moves when it is said to have\nchanged; nothing else travels. The page learns versions at render, one SSE\nstream per tab (only on a page with names) watches them, and a moved version\ncalls refresh(section) for exactly the regions holding the name. Versions\nlive where the backend keeps shared state (Laravel's cache, a table in Go\nvia reg.Versions(&rsckit.SQLVersions{...}), this process without a backend). Go\nholds the ask and answers the moment a name moves; Laravel (PHP-FPM) answers\nat once and the renderer asks again ~2s later. Runs on Workers/Vercel: the\nstream is stateless, names are signed by the renderer (a tab may only watch\nnames its page was rendered with).\nFROM NEXT - this replaces revalidateTag, and is not the same thing: Next's\ntags label Data Cache entries and revalidateTag() purges them (the NEXT\nrequest refetches; open tabs learn nothing). rsc-kit has no data cache -\nevery render is fresh - so refreshOn names what is on screen and changed()\nrefreshes every open tab now. Port: tags: ['posts'] on the fetch ->\nrefreshOn: ['posts'] on the section; revalidateTag('posts') -> changed('posts').\nWHEN - use names when the change comes from OUTSIDE the tab: webhooks, jobs,\nteammates. Use revalidate('orders') inside the action that made the change.\nUse usePolling for a value with no backend signal; useEvents for a stream of\nvalues (live-data).\nTEST - testChanges() from @rsc-kit/core/testing: host: { ...names.host };\nnames.changed('team:1:repos').\nA name's name is not secret, but what its version reveals is only \"something\nnamed this changed\"; do not put secrets in name names.`,\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. For a backend-side change (webhook, job) see refresh-on',\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(\\`/api/orders/\\${id}/events\\`)\n// latest is typed from the route: what its generator yields. { event: 'paid' }\n// types the named messages. A url no route answers fails the typecheck; another\n// site's stream is unknown until named: useEvents<Message>(url).\n\\`\\`\\`\nevents() frames JSON, sends a keepalive, sets text/event-stream + no-store,\nends the generator on disconnect (signal). EventSource reconnects a dropped\nconnection and resumes with Last-Event-ID when you yielded ids; after an error\nanswer (a 502 mid-deploy) the hook opens a new one, backing off 1s to 30s, and\nat once when the browser is back online. close() stops it.\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(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(url, { onMessage: setOrder }) // useState\n useEvents(url, { onMessage: (m) => dispatch({ type: 'update', m }) }) // reducer\n useEvents(url, { onMessage: (m) => queryClient.setQueryData(['order', id], m) }) // TanStack\n useEvents(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\nA library written for Next that reads next/headers (flags/next) needs the\nbuild's alias in tests/preload.ts: mock.module('next/headers', () => import('@rsc-kit/core/request')).\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 anything the build is made from is newer than the last build -\nsource, vite.config, package.json, the lockfile, the installed @rsc-kit/core -\nso an upgrade is picked up. This is where a guard that never ran or a 404 that\ncame back 200 shows up.\n\nTo assert something is NOT on a page, use app.markup(path), not fetch(): the\ndocument streams the page's payload in <script>s, which holds every prop a\nclient component was given, shown or not. markup() is the document with every\n<script> removed. Do NOT strip scripts by hand.\n\n**A Go or Laravel backend, answered in the test (nothing running):**\n\n\\`\\`\\`ts\nimport { createTestApp, hostReply, HOST_MIDDLEWARE } from '@rsc-kit/core/testing'\nconst app = await createTestApp({\n host: {\n 'Apps.list': ({ args, headers }) => [{ id: 1 }], // rpc('Apps.list', ...)\n 'Apps.create': () => hostReply.invalid({ name: ['Taken.'] }),\n [HOST_MIDDLEWARE]: ({ args, headers }) => // middleware.ts guards; args[0] = names\n headers.get('cookie')?.includes('session=') ? true : hostReply.unauthenticated(),\n },\n})\n\\`\\`\\`\n\nThe host handlers are TYPED from rsc-host.json (interface RscHostFunctions in\n.rsc-kit/rsc-env.d.ts): only functions the backend has, typed args, and the\nfunction's result type or a hostReply answer - never a raw { status: 401 } or a\nmade-up shape. Refusals ALWAYS through hostReply, which passes the same\nconformance suite the real adapters do. hostReply.fail(message) = an unexpected\nerror. Answers go through the real host-call client, so they behave as the backend's\nwould: hostReply.unauthenticated() -> 401, unauthorized() -> 403,\nredirect(to), refuse(status, message), invalid(errors) -> validationErrors,\nrevalidating(result, ...regions). A name with no handler fails the call,\nnaming it. RSC_BACKEND in .env is not called.\n\nA url the app forwards to the backend (a Go route, /login) is a 502 in a test\nunless createTestApp({ backend: (request) => new Response(...) }) answers it;\nit gets the request as the backend would.\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"]}
@@ -108,7 +108,10 @@ browser → renderer ──render──▶ React
108
108
  the React tree has it, React renders it; otherwise the backend does. Nothing
109
109
  is half-and-half on one url. A dynamic root route that would also match one
110
110
  of the backend's urls does not get it: name the backend's prefixes with
111
- `backendPaths` and they go first.
111
+ `backendPaths` and they go first. A named prefix is also never a page to the
112
+ browser: a `Link`, `visit()` or `redirect()` to it loads the document and is
113
+ never prefetched, so it may bounce on to another site, as an OAuth start does
114
+ ([redirects](/guides/redirects/#leaving-the-app)).
112
115
  - **Calls are batched, and answered as they finish.** Sibling components
113
116
  each awaiting `rpc()` are one request to the backend, which answers one
114
117
  line per call the moment that call is done — so a fast read paints while
package/guides/forms.md CHANGED
@@ -301,6 +301,41 @@ export default function SearchForm() {
301
301
 
302
302
  This navigates to `/search?q=hello&sort=date` via SPA navigation. The nearest Suspense boundary streams in the results. With `prefetch="hover"`, hovering the submit button pre-warms the base URL for instant feedback.
303
303
 
304
+ ### A search on the page it is on
305
+
306
+ When the form's `action` is the page the visitor is already on, there is no other page whose shell could stand in while the query runs: the shell would be this page with nothing in it. So the form does not show one. The page stays on screen, results and all, and the new results replace the old ones when they arrive. The input keeps what was typed, a Suspense boundary that was already showing its content keeps showing it instead of falling back to its skeleton, and Back returns to the query the visitor left.
307
+
308
+ `pending` is true until the new results are on screen, so the button can say so:
309
+
310
+ ```tsx title="Lookup.tsx"
311
+ "use client";
312
+
313
+ import { Form } from "@rsc-kit/core/form";
314
+
315
+ export default function Lookup() {
316
+ return (
317
+ <Form action="/lookup" method="get">
318
+ {({ pending }) => (
319
+ <>
320
+ <input name="q" />
321
+ <button disabled={pending}>{pending ? "Searching…" : "Search"}</button>
322
+ </>
323
+ )}
324
+ </Form>
325
+ );
326
+ }
327
+ ```
328
+
329
+ To show a skeleton where the results are instead of the old results, style them from the form's own `data-pending` attribute. The results stay outside the form, and nothing in the page is a client component for it:
330
+
331
+ ```css
332
+ #results-skeleton { display: none }
333
+ section:has(form[data-pending]) #results { display: none }
334
+ section:has(form[data-pending]) #results-skeleton { display: block }
335
+ ```
336
+
337
+ Only a `<Form>` does this. A `<Link>` or `visit()` to the same path with another query is still a page of its own. The form does not prefetch the page it is on, because it is already on it.
338
+
304
339
  ## Using it with shadcn/ui
305
340
 
306
341
  It works, and mostly by doing nothing. `<Form>` reads a native `FormData`, so
package/guides/go.md CHANGED
@@ -78,6 +78,11 @@ are refused where they are written, and with no backend where the server runs
78
78
  such a url is the app's not-found page. A test reaches it as it does any
79
79
  backend url, with `createTestApp({ backend })`.
80
80
 
81
+ In the browser a named prefix is never a page. A `Link`, `visit()` or
82
+ `redirect('/gitlab/connect')` to it loads the document and is never
83
+ prefetched, so the Go route may answer with a redirect to another site, as an
84
+ OAuth start does — see [redirects](/guides/redirects/#leaving-the-app).
85
+
81
86
  ### Running it locally
82
87
 
83
88
  Two processes: the Go program, and Vite, which is the renderer in
package/guides/laravel.md CHANGED
@@ -474,6 +474,11 @@ Each is a prefix at a segment boundary, for every method, and a team called
474
474
  `auth` is then unreachable. With Laravel in front instead, the routes you
475
475
  registered win over a page route's, which is registered after them.
476
476
 
477
+ In the browser a named prefix is never a page. A `Link`, `visit()` or
478
+ `redirect('/oauth/github')` to it loads the document and is never
479
+ prefetched, so the Laravel route may answer with a redirect to another site, as
480
+ a Socialite redirect does — see [redirects](/guides/redirects/#leaving-the-app).
481
+
477
482
  Where it forwards to is decided at build time: `vite build` reads `APP_URL`
478
483
  (or `RSC_BACKEND`) from `.env` and bakes it in, while host calls read the same
479
484
  names from the process at runtime. Building on a machine whose `.env` names a
@@ -121,8 +121,9 @@ In a console, after a few clicks:
121
121
  performance.getEntriesByName('rsc-kit:navigate').map((m) => Math.round(m.duration))
122
122
  ```
123
123
 
124
- A document load the router chose — a route.ts, a foreign origin, a newer
125
- build, a payload that was not one, a chunk the deploy no longer serves — is
124
+ A document load the router chose — a route.ts or a url named in
125
+ `backendPaths`, a foreign origin, a newer build, a payload that was not one, a
126
+ chunk the deploy no longer serves — is
126
127
  announced before it happens, as a `rsc-kit:document-load` event on `window`
127
128
  with the url and the reason, so a reload under a tester has a name:
128
129
 
@@ -40,6 +40,42 @@ redirect('/old', { status: 308 }); // a permanent one
40
40
 
41
41
  A bare second argument is still the status: `redirect('/old', 308)`.
42
42
 
43
+ ## Leaving the app
44
+
45
+ A destination does not have to be one of this app's pages. Two kinds are not,
46
+ and a redirect to either is a full page load rather than a navigation — the
47
+ router does not fetch them as pages, because neither answers one:
48
+
49
+ ```tsx
50
+ // Another site. Any http(s) url; it type-checks as written.
51
+ redirect('https://gitlab.com/oauth/authorize?client_id=…');
52
+
53
+ // A url your backend owns, named in backendPaths (below). The build types the
54
+ // prefix, so this needs no cast either.
55
+ redirect('/gitlab/connect');
56
+ ```
57
+
58
+ It works the same wherever the redirect is called, above a `<Suspense>`
59
+ boundary or inside one, and on a first visit as on a click. In every case the
60
+ url that redirected is replaced in history, so Back from the other site lands
61
+ on the page before it rather than on one that sends you out again.
62
+
63
+ The second kind needs its prefix named, because the app cannot otherwise tell
64
+ the backend's url from a page it has not built yet:
65
+
66
+ ```ts title="vite.config.ts"
67
+ rscKit({ backendPaths: ['/auth', '/github', '/gitlab'] })
68
+ ```
69
+
70
+ A name in `backendPaths` is also what keeps the router from asking for that url
71
+ as a page. Without it, the router fetches `/gitlab/connect` for its payload, the
72
+ backend answers with a redirect to another site (an OAuth start does), and the
73
+ browser refuses that hop — a fetch cannot follow a redirect to another origin
74
+ with the headers the router sends. The click then does nothing: the old page
75
+ stays and the url does not move. Named, a `Link`, `visit()` or `redirect()` to
76
+ it loads the document, and it is never prefetched — a hover cannot start the
77
+ flow. `backendPaths` is [described with the backend](/hosts/backend-answered-pages/).
78
+
43
79
  ## Where you call it matters
44
80
 
45
81
  This is the part worth understanding, because it is also the security-relevant
@@ -124,10 +124,21 @@ tsconfig whose `include` does not cover `.rsc-kit` — nothing is registered,
124
124
  every url-taking prop stays exactly as permissive as a plain `string`, and
125
125
  nothing breaks. There is no flag to turn this on.
126
126
 
127
- ## `redirect()` is not typed
127
+ ## `redirect()` is typed
128
128
 
129
- Its destination is usually computed — read from a cookie, handed over by
130
- middleware — so typing it would make the common case a cast.
129
+ `redirect('/login')` is checked like a `Link`, so a redirect to a page that was
130
+ renamed or deleted stops compiling instead of sending someone to a 404. A
131
+ destination that is computed — read from a cookie, handed over by middleware —
132
+ is a string the compiler cannot check, so it takes the cast:
133
+
134
+ ```ts
135
+ redirect('/login'); // ✅
136
+ redirect('/logn'); // ❌
137
+ redirect(returnTo as Route); // computed: you vouch for it
138
+ ```
139
+
140
+ A page's `search` is checked on `redirect()` too, against the destination's own
141
+ `searchParams` schema — see [redirects](/guides/redirects/#with-a-query-string).
131
142
 
132
143
  ## Api routes
133
144
 
@@ -167,6 +178,24 @@ query](/guides/queries/) is already typed across the boundary: the return type
167
178
  is the function's, because it is the same function.
168
179
  :::
169
180
 
181
+ ## The backend's urls
182
+
183
+ A prefix named in `backendPaths` is a `Route` too, so a link or a redirect to a
184
+ url the backend owns compiles as written:
185
+
186
+ ```tsx
187
+ // vite.config.ts: rscKit({ backendPaths: ['/gitlab'] })
188
+ <Link href="/gitlab/connect">Connect GitLab</Link> // ✅
189
+ redirect('/gitlab/connect'); // ✅
190
+ <Link href="/gitlabs">…</Link> // ❌ not the prefix
191
+ ```
192
+
193
+ The prefix is a segment boundary, as it is for the server: `/gitlab`, anything
194
+ under it, and a query or fragment, never `/gitlabs`. What is behind it is the
195
+ backend's, so nothing past the prefix is checked. Like a `route.ts`, the browser
196
+ loads it as a document and never prefetches it — see [leaving the
197
+ app](/guides/redirects/#leaving-the-app).
198
+
170
199
  ## Regions
171
200
 
172
201
  `revalidate()` in an action and `refresh()` in the browser take a region by
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rsc-kit/mcp",
3
- "version": "0.30.20",
3
+ "version": "0.30.22",
4
4
  "description": "An MCP server over what an rsc-kit build decided: the routes, why each one is static or not, and what it costs the browser.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -34,7 +34,7 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "@modelcontextprotocol/sdk": "^1.30.0",
37
- "create-rsc-kit": "^0.30.20",
37
+ "create-rsc-kit": "^0.30.22",
38
38
  "zod": "^3.25"
39
39
  },
40
40
  "devDependencies": {