@rsc-kit/mcp 0.30.19 → 0.30.21

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
@@ -1793,13 +1793,16 @@ is a GETTER: do NOT destructure it ((args, { signal, deadline }) reads it once,
1793
1793
  at the start, when it is always undefined, and keeps that); read context.deadline
1794
1794
  when you need it. signal is a plain property and can be destructured.
1795
1795
 
1796
- A stop that arrives before the command starts - while the app loads, or while
1797
- register() runs - finds the signal ALREADY aborted: the runner gives the event
1798
- loop one turn before calling the command, so a waiting stop is delivered first
1799
- (the same on Node, Bun and a binary). Its 'abort' event has fired by then. Check
1800
- signal.aborted before waiting on the event, never only
1801
- addEventListener('abort') (it would wait for ever); a loop on signal.aborted
1802
- needs nothing more.
1796
+ A stop reaches a command one of two ways, so a command that waits needs BOTH
1797
+ the check and the listener. BEFORE it starts (while the app loads, tens of ms, or
1798
+ during register()) the signal is ALREADY aborted and its 'abort' event has fired -
1799
+ the runner gives the event loop one turn before calling the command, so a waiting
1800
+ stop is delivered first (the same on Node, Bun and a binary). AFTER it starts
1801
+ (the usual case) signal.aborted turns true and 'abort' fires while it runs. So:
1802
+ check signal.aborted as the command starts, and listen for 'abort' - or pass
1803
+ signal to something that does ({ signal } to a queue, sleep(ms, undefined,
1804
+ { signal })). Checking alone misses a stop during a long await; listening alone
1805
+ waits for ever on a stop that came first. The loop below does both.
1803
1806
 
1804
1807
  import { defineCommand } from '@rsc-kit/core/commands'
1805
1808
 
@@ -2055,7 +2058,8 @@ write, no library.
2055
2058
  \`\`\`tsx
2056
2059
  // src/app/t/[team]/repos.section.tsx
2057
2060
  import { section } from '@rsc-kit/core/section'
2058
- export default section('repos', Repos, { refreshOn: ({ params }) => [\`team:\${params.team}:repos\`] })
2061
+ const ReposSection = section('repos', Repos, { refreshOn: ({ params }) => [\`team:\${params.team}:repos\`] })
2062
+ export default ReposSection // named, so the editor offers it as an import: a bare export default section(...) is offered as reposSection
2059
2063
  // a page: export const refreshOn = ({ params }) => [...] -> a change refreshes the page
2060
2064
  // typed from its schemas: export const refreshOn: PageRefreshOn<typeof params> = ({ params }) => [...]
2061
2065
  // (import type { PageRefreshOn } from '@rsc-kit/core/section'; params arrive awaited + parsed)
@@ -2063,6 +2067,18 @@ export default section('repos', Repos, { refreshOn: ({ params }) => [\`team:\${p
2063
2067
  // a misspelt param or a pattern that is not a route fails the typecheck
2064
2068
  // export const / function / { refreshOn } / { refreshOn } from './x' all count; export * from does not
2065
2069
  \`\`\`
2070
+ A LAYOUT'S REGION - what is the same on every tab (a title, a status, the tab bar)
2071
+ belongs in the layout, which stays mounted while the pages under it change, and
2072
+ when it shows data that changes it is a section there. A page refreshes the
2073
+ sections beside it and then those beside its layouts, nearest first, so
2074
+ revalidate('header') from an action on any tab, or a refreshOn change, renders
2075
+ just that. Put it in its own <Suspense> in the layout (it reads params, and a
2076
+ layout that awaits them above every boundary fails the build). Pass
2077
+ params={params} through; a refresh renders the section with the page's props, which hold every segment the layout's do. A name
2078
+ is unique along the chain (the nearer one wins). The active tab is usePathname()
2079
+ in a client component, not a refresh. Do NOT draw the header in each tab's page
2080
+ inside its Suspense: a tab switch mounts a new page, and its fallback replaces
2081
+ the header until the data arrives.
2066
2082
  PARENT AND CHILD - a change to a child changes its parent's list: say both
2067
2083
  where the write is - changed(\`app:\${id}\`, \`team:\${teamId}:apps\`) - and let each
2068
2084
  region name only what it shows. Never make a list watch every child's name.
@@ -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 that arrives before the command starts - while the app loads, or while\nregister() runs - finds the signal ALREADY aborted: the runner gives the event\nloop one turn before calling the command, so a waiting stop is delivered first\n(the same on Node, Bun and a binary). Its 'abort' event has fired by then. Check\nsignal.aborted before waiting on the event, never only\naddEventListener('abort') (it would wait for ever); a loop on signal.aborted\nneeds nothing more.\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'\nexport default section('repos', Repos, { refreshOn: ({ params }) => [\\`team:\\${params.team}:repos\\`] })\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\\`\\`\\`\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 },\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"]}
@@ -126,15 +126,30 @@ command that does not stop in time exits `1`, and says so. A second signal does
126
126
  not wait.
127
127
 
128
128
  `signal.aborted` is the state; the `abort` event is the moment it changed, and
129
- it happens once. A stop that arrives before the command starts, while the app
130
- loads or while `register()` runs, finds `signal` already aborted: the runner
131
- gives the event loop one turn before it calls the command, so a stop that was
132
- waiting is delivered first, the same on Node, on Bun and in a binary. The event
133
- has fired by then. So check `signal.aborted` before you wait on it, as the loop
134
- above does, or pass `signal` to something that does (`sleep(ms, undefined, { signal })`
135
- rejects at once). A command that only ever loops on `signal.aborted` needs
136
- nothing more; one that waits on `addEventListener('abort', …)` and does not
137
- check first can wait for ever.
129
+ it happens once. A stop reaches a command one of two ways, so a command that
130
+ waits needs both:
131
+
132
+ - **Before it starts.** A stop that arrives while the app loads (tens of
133
+ milliseconds) or while `register()` runs finds `signal` already aborted, and
134
+ the event has fired before the command could listen for it. The runner gives
135
+ the event loop one turn before it calls the command, so a stop that was
136
+ waiting is delivered first, the same on Node, on Bun and in a binary.
137
+ - **After it starts.** The usual case: `signal.aborted` turns true and the
138
+ `abort` event fires while the command is running.
139
+
140
+ So check `signal.aborted` as the command starts, and listen for `abort` for the
141
+ rest, or pass `signal` to something that does (`sleep(ms, undefined, { signal })`
142
+ rejects at once). Checking alone misses a stop that arrives during a long
143
+ `await`; listening alone waits for ever on a stop that came first. The worker
144
+ above does both: the loop tests `signal.aborted`, and `queue.processNext({ signal })`
145
+ returns when the signal aborts. A command that only waits does it by hand:
146
+
147
+ ```ts
148
+ export default defineCommand(async (_args, { signal }) => {
149
+ if (signal.aborted) return
150
+ await new Promise<void>((resolve) => signal.addEventListener('abort', () => resolve(), { once: true }))
151
+ })
152
+ ```
138
153
 
139
154
  `deadline` is when the runner stops waiting, as a `Date.now()` time, and is
140
155
  `undefined` until the command has been told to stop. A worker finishing its
@@ -184,7 +199,8 @@ loudly: 1.2 says `cannot use --compile with --splitting`.
184
199
  `dist` that grew to 201 MB). The binary does not read them. A stack trace is
185
200
  the same with them deleted, so ship the binary alone and remove them
186
201
  (`rm -f dist/*.map`), or leave `--sourcemap` off, which is what the scaffold
187
- does. `--sourcemap=inline` does not prevent them under `--compile`.
202
+ does. `--sourcemap=inline` does not prevent them under `--compile`. What
203
+ a trace shows either way is in [reading a stack trace](/hosts/where-it-runs/#reading-a-stack-trace).
188
204
  </Aside>
189
205
 
190
206
  The same stop, 250 ms into starting a binary of 80 MB, exits `143` without it
@@ -8,7 +8,7 @@ that part, so an action can refresh it on its own.
8
8
  ```tsx title="src/app/orders/orders.section.tsx"
9
9
  import { section } from '@rsc-kit/core/section'
10
10
 
11
- async function Orders() {
11
+ async function OrdersList() {
12
12
  const orders = await db.orders()
13
13
 
14
14
  return (
@@ -18,9 +18,16 @@ async function Orders() {
18
18
  )
19
19
  }
20
20
 
21
- export default section('orders', Orders)
21
+ const Orders = section('orders', OrdersList)
22
+
23
+ export default Orders
22
24
  ```
23
25
 
26
+ Give the section a name before exporting it, as above, rather than writing
27
+ `export default section(...)`. An editor offers an import by the name a default
28
+ export has, and a call has none: it falls back to one made up from the file name,
29
+ `OrdersSection`, so typing `<Orders` finds nothing to import.
30
+
24
31
  Render it like any other component:
25
32
 
26
33
  ```tsx title="src/app/orders/page.tsx"
@@ -61,11 +68,71 @@ exactly as it was.
61
68
  An action says what it changed, not which page called it. A backend's
62
69
  action cannot know at all: Go's `rsckit.Revalidate(ctx, "orders")` and
63
70
  Laravel's `Rsc::revalidate('orders')` name the data. When the page that
64
- called the action has no `orders` section, there is nothing on screen to
65
- refresh, and the name is skipped; the action still answers. A name no page
71
+ called the action has no `orders` section - its own or one beside its layouts -
72
+ there is nothing on screen to refresh, and the name is skipped; the action still answers. A name no page
66
73
  in the app has is a mistake, and the action fails, naming the sections and
67
74
  slots the calling page does have.
68
75
 
76
+ ## In a layout
77
+
78
+ A layout stays mounted while you move between the pages under it. So the part of
79
+ a screen that is the same on every tab - a title, a status, the tab bar - belongs
80
+ there, where it does not go away and come back when a tab is clicked. When that
81
+ part shows data that changes, make it a section, and any page under the layout
82
+ can refresh it by name:
83
+
84
+ ```tsx title="src/app/[team]/[app]/layout.tsx"
85
+ import { Suspense } from 'react'
86
+ import Header from './header.section'
87
+
88
+ export default function AppLayout({ params, children }) {
89
+ return (
90
+ <>
91
+ <Suspense fallback={<HeaderSkeleton />}>
92
+ <Header params={params} />
93
+ </Suspense>
94
+ {children}
95
+ </>
96
+ )
97
+ }
98
+ ```
99
+
100
+ ```tsx title="src/app/[team]/[app]/header.section.tsx"
101
+ import { section } from '@rsc-kit/core/section'
102
+
103
+ async function AppHeader({ params }) {
104
+ const { team, app } = await params
105
+ const status = await db.status(team, app)
106
+
107
+ return <header>{status}</header>
108
+ }
109
+
110
+ const Header = section('header', AppHeader, {
111
+ refreshOn: ({ params }) => [`app:${params.app}`],
112
+ })
113
+
114
+ export default Header
115
+ ```
116
+
117
+ The header reads `params`, so it has its own `<Suspense>`: the layout paints with
118
+ the skeleton at once and the header streams in. A layout that awaits `params`
119
+ above every boundary is refused at build, on a route that lists no urls.
120
+
121
+ `revalidate('header')` from an action called on any page under the layout
122
+ refreshes it, and so does `refreshOn`. A page's sections are the ones beside it
123
+ and then the ones beside each of its layouts, nearest first, so:
124
+
125
+ - **The name is unique along the chain.** A page's `header` and its layout's are
126
+ one name on one screen; the nearer one is the one a refresh renders.
127
+ - **A refresh renders the section with the page's props**, `params` and
128
+ `searchParams`, awaitable as the page's own are, not the ones the layout
129
+ passed. Pass `params` through, as above, and read what the section needs from
130
+ it: a page's `params` hold every segment the layout's do.
131
+ - **The active tab is not a refresh.** Mark it from `usePathname()` in a client
132
+ component inside the section; it follows the url on every navigation.
133
+
134
+ A tab switch then replaces only the page. The header is already there.
135
+
69
136
  ## Refreshing on a change from outside
70
137
 
71
138
  An action refreshes the sections it changed. For a change made anywhere else -
@@ -76,8 +143,8 @@ That has its own guide: [Live data](/guides/live-data).
76
143
  ## The name is scoped to the module, not the app
77
144
 
78
145
  Two pages may both call their section `orders`. The name is resolved through the
79
- module the route declares, not through a table every section in the app writes
80
- to.
146
+ modules the route declares - the sections beside the page and beside its
147
+ layouts - not through a table every section in the app writes to.
81
148
 
82
149
  That is a security property rather than a convenience. A name-keyed registry is
83
150
  populated by every section at bundle load, so a lookup by name could reach any
@@ -138,6 +138,35 @@ bytecode, as a module.
138
138
  The generated config sets it. If you write your own, set it too.
139
139
  </Aside>
140
140
 
141
+ ## Reading a stack trace
142
+
143
+ A trace from a built app names the build output, not `src/`:
144
+
145
+ ```
146
+ at zz_throw_default (.output/server/_ssr/zz-throw-D9B2cblU.mjs:7:12)
147
+ ```
148
+
149
+ That file is Nitro's copy of the chunk Vite built for the module. It is named
150
+ for the module (`zz-throw` is `src/commands/zz-throw.ts`, `Counter` is
151
+ `Counter.tsx`) plus a hash that changes every build, and it is not in the
152
+ binary's directory or shipped with it, so the path is a label and not a file
153
+ you can open. The line and column are the chunk's, not the source's. Frames
154
+ from the engine and the rest of the app show `rsc.mjs:<line>`.
155
+
156
+ Source maps do not change this today:
157
+
158
+ - **Bun ignores input source maps.** `bun .output/server/commands.mjs` and a
159
+ binary compiled with `--sourcemap` both show the chunk path, even when the
160
+ chunk has a map that is correct (a hand-made one resolved to the exact source
161
+ line under Node and was ignored under Bun). `--sourcemap` maps the binary's
162
+ bundle back to its input files and no further. That is Bun's limit, and
163
+ rsc-kit does not work around it.
164
+ - **Nitro writes the maps for `_ssr/` with no mappings.** Vite's own map for
165
+ the chunk is correct and points at `src/`, but Nitro's copy of the chunk has
166
+ a map with a source and nothing else, so `node --enable-source-maps` shows the
167
+ chunk too (checked with `build.sourcemap` and Nitro's `sourcemap` both on).
168
+ This is Nitro's, and fixing it would help only a Node server.
169
+
141
170
  ## Native dependencies stay outside the bundle
142
171
 
143
172
  A package with a native binary — `sharp`, `bcrypt`, `better-sqlite3`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rsc-kit/mcp",
3
- "version": "0.30.19",
3
+ "version": "0.30.21",
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.19",
37
+ "create-rsc-kit": "^0.30.21",
38
38
  "zod": "^3.25"
39
39
  },
40
40
  "devDependencies": {