@rsc-kit/mcp 0.30.35 → 0.30.36
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 +10 -2
- package/dist/recipes.js.map +1 -1
- package/guides/deployment.md +8 -0
- package/guides/getting-started.md +3 -1
- package/package.json +2 -2
package/dist/recipes.js
CHANGED
|
@@ -2011,7 +2011,12 @@ Bun's, not the framework's: bun test loads the package .env (use
|
|
|
2011
2011
|
errno where Node's pg uses code.
|
|
2012
2012
|
|
|
2013
2013
|
Never NODE_ENV in a .env (the build refuses it, naming the line). Build
|
|
2014
|
-
machines without secrets: SKIP_ENV_VALIDATION=1.
|
|
2014
|
+
machines without secrets: SKIP_ENV_VALIDATION=1. A built server started
|
|
2015
|
+
with no NODE_ENV sets the one it was built with in its own process before
|
|
2016
|
+
the app loads, so process.env.NODE_ENV and env.NODE_ENV are 'production' at
|
|
2017
|
+
run time - do NOT add NODE_ENV=production to a start command or a Dockerfile.
|
|
2018
|
+
That makes rsc-kit's production checks run: an app using refreshOn sets
|
|
2019
|
+
RSC_SIGNING_SECRET or its live pages answer 500.
|
|
2015
2020
|
|
|
2016
2021
|
reflect-metadata (tsyringe, typeorm, inversify - often under
|
|
2017
2022
|
@simplewebauthn/server): nothing to import; when the graph has it the build
|
|
@@ -2146,7 +2151,10 @@ export const env = createEnv({
|
|
|
2146
2151
|
|
|
2147
2152
|
Read env.DATABASE_URL, never process.env.DATABASE_URL: the first is typed and
|
|
2148
2153
|
was checked at startup (a missing or malformed one fails then, with its name),
|
|
2149
|
-
the second is string | undefined.
|
|
2154
|
+
the second is string | undefined. env.NODE_ENV is 'production' on a built
|
|
2155
|
+
server with nothing set (the server sets it from the build's mode before the
|
|
2156
|
+
app loads), so its .default('development') is only a dev server's or a
|
|
2157
|
+
script's. A server variable never reaches the browser;
|
|
2150
2158
|
a browser-readable one MUST start with VITE_ and is read from import.meta.env
|
|
2151
2159
|
(Vite's own prefix and the only one - a PUBLIC_* variable, once accepted
|
|
2152
2160
|
beside it, fails the build by name), which is why runtimeEnv merges both. Commit .env.example, not .env.
|
package/dist/recipes.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"recipes.js","sourceRoot":"","sources":["../src/recipes.ts"],"names":[],"mappings":"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","sourcesContent":["// How to build the things this framework has, in the shape that works.\n//\n// The other half of this server, and the more useful one. Introspection answers\n// \"what did my build do\"; this answers \"how do I do X here\", which is the\n// question an agent actually has — and the one it otherwise answers from Next\n// and React habits that produce code which looks right and is not.\n//\n// Long-form on purpose. AGENTS.md has to be short enough to sit in context for\n// every turn, so it can only say the rule. These are fetched when the topic\n// comes up, so they can afford the working example and the caveat under it.\n//\n// Every snippet here is the recommended spelling from the guides, not a\n// paraphrase. When a guide changes, this changes with it — a recipe that has\n// drifted is worse than no recipe, because it is followed with confidence.\n\nexport interface Recipe {\n topic: string\n summary: string\n body: string\n}\n\nconst RECIPES: Recipe[] = [\n {\n topic: 'forms',\n summary: 'Submitting to a server action, with pending state and field errors. Uncontrolled by default; read with error() and field()',\n body: `THE RULE: forms are UNCONTROLLED. Inputs keep their value in the DOM,\nan initial value is defaultValue, the action reads FormData. Do NOT write\nuseState + value/onChange per input, and do NOT reach for TanStack Form.\n\nONE WAY TO READ A FORM - two words, the same everywhere:\n error('title') the field's error, or undefined. !!error('title') is invalid.\n Nested fields are paths: error('address.city'), error('items[0].name').\n The names are TYPED from the action's input (a stub's typed\n parameter, or createActionClient().input(schema)), so\n error('titel') fails tsc: Field names. Open when the action\n says nothing (a FormData function, a url, a cast). A component\n below the form types its prop with FieldNamesOf<typeof action>\n from '@rsc-kit/core/form'. A WRAPPER you write around a stub\n (a function of a FormData) declares its fields on its\n parameter: (form: FormFields<'id' | 'name'>) - then error()\n is closed to them. A TEAM-SCOPED stub func(ctx, team, in) is a\n form's action as stub.bind(null, team): the form fills the\n struct after the bound args, and the names stay - but bind\n into a const first, inline bind() loses them. A stub whose\n parameters are plain strings has no struct for a form to fill:\n wrap it. A form that posts NOTHING (values in React state)\n names its fields on the form: <Form fields={['size']}>.\n formError the refusal NOT about a field (serverError - a 402/409 - or a\n form-level validation message), or undefined. Render it as\n {formError && <p role=\"alert\">{formError}</p>}; do not keep it\n from onError in your own state.\n field('title') a binding to spread - ONLY for a field whose value the UI must\n hold (a character count, a live preview, a control with no\n native element). Everything else stays uncontrolled.\nBeside them: pending, dirty, succeeded, recentlySucceeded, reset(), clearErrors(),\nand data (what is being submitted).\n\nThere is nothing else. No errors map, no fieldState, no useFormValues, no\nuseFormStatus - those were removed; if you see them in old code, convert them.\n\nUse <Form>. It takes the server action itself, not a url.\n\n\\`\\`\\`tsx\n'use client'\nimport Form from '@rsc-kit/core/form'\nimport { createPost } from '../actions'\n\nexport function NewPost() {\n return (\n <Form action={createPost} schema={schema}>\n {({ pending, error }) => (\n <>\n <input name=\"title\" />\n {error('title') && <p>{error('title')}</p>}\n <button disabled={pending}>Save</button>\n </>\n )}\n </Form>\n )\n}\n\\`\\`\\`\n\nIn a child component, useForm() returns EXACTLY what the render prop gets, so\nthe code is the same:\n\n\\`\\`\\`tsx\nimport { useForm } from '@rsc-kit/core/form'\n\nfunction SubmitButton() {\n const { pending } = useForm()\n return <button disabled={pending}>Save</button>\n}\n\\`\\`\\`\n\n(Not React's useFormStatus - a different hook with a different answer.)\n\nPassing \\`schema\\` validates in the browser BEFORE the action is called, so a\nmistake costs no round trip. It is a courtesy, never a control: the action is a\npublic endpoint reachable without your form, so the server must check too.\n\nA schema on the server (\\`client.input(schema)\\`) does NOT give you client-side\nvalidation. Pass it to the form as well — the same schema is fine.\n\nA field is checked when it is LEFT, not as it is typed (the form listens for\nfocusout, so uncontrolled fields are covered), and on submit. So error() has\nnothing for a field nobody reached - there is no \"touched\" to check first.\n\nValues are uncontrolled, so an initial one is React's own \\`defaultValue\\`. A\nrefused submit keeps what was typed, because the DOM kept it.\n\nA repeated name is an array. With one selected it is a string, which no\nz.array() accepts - so for anything that is a list by nature end the name in\n\\`[]\\` and it is always an array, brackets dropped from the key:\n\n\\`\\`\\`tsx\n<input type=\"checkbox\" name=\"tags[]\" value=\"react\" /> // -> { tags: ['react'] }\n\\`\\`\\`\n\nNames that describe a shape build it: \\`address.city\\` nests, and\n\\`items[0].name\\` (or \\`items[0][name]\\`) makes an array of objects. That is\nthe shape the schema was written against, and error() takes the same path.\n\nBinding a field - a rich editor, a Radix select, a value read as it is typed:\n\n\\`\\`\\`tsx\n<Form action={save} defaultValues={{ body: '' }}>\n {({ field, error }) => (\n <>\n <Editor {...field('body')} />\n <span>{field('body').value.length}/100</span>\n {error('body') && <p>{error('body')}</p>}\n </>\n )}\n</Form>\n\\`\\`\\`\n\nfield() gives { name, value, onChange, onBlur } - react-hook-form Controller's\nfour. onChange takes a DOM event OR a bare value. A bound field is still an\nordinary named input, so it arrives in FormData with the rest.\n\nshadcn Field components, with the two words:\n\n\\`\\`\\`tsx\n<Field data-invalid={!!error('title')}>\n <FieldLabel htmlFor=\"title\">Title</FieldLabel>\n <Input id=\"title\" name=\"title\" aria-invalid={!!error('title')} />\n <FieldError>{error('title')}</FieldError>\n</Field>\n\\`\\`\\`\n\nFieldError renders its children and renders nothing when there are none.\n\nA bound field re-renders the form. For a LARGE controlled form, put the field in\nits own component and use useField - the same two words for one name, and it\nre-renders that component and nothing else:\n\n\\`\\`\\`tsx\nfunction Title() {\n const { field, error } = useField('title')\n return <Input {...field} aria-invalid={!!error} />\n}\n\\`\\`\\`\n\nA preview or summary reads a bound value the same way:\n\\`const { field } = useField('title'); field.value\\`. Only BOUND values can be\nread - an uncontrolled input's value is the DOM's.\n\nFor something that is NOT a descendant - a top bar - create the store above both\nand hand it in; useField(name, store) reads it:\n\n\\`\\`\\`tsx\nconst store = useFormStore({ title: '' })\n\n<TopBar store={store} /> // outside the form: useField('title', store)\n<Form action={save} store={store}>…</Form>\n\\`\\`\\`\n\nCreating the store does not subscribe to it, so the holder does not re-render\nper keystroke.\n\nA submit from outside the form is html, not a second api:\n\n\\`\\`\\`tsx\n<Form id=\"bug-report\" action={reportBug}>…</Form>\n<Button type=\"submit\" form=\"bug-report\">Submit</Button>\n\\`\\`\\`\n\nIt works before hydration. The action is on the form element as well as in the\nsubmit handler, so the markup is submittable on its own; a refusal shows on its\nfields through error() even with no javascript.\n\n**components.json needs \"rsc\": true.** shadcn's init sees a plain Vite\nproject and writes false, and then a dialog or dropdown it adds has no\n\"use client\". create-rsc-kit --shadcn and rsc-kit init --shadcn set it; after\na bare \\`shadcn init\\`, set it by hand. \\`import { cn } from \"cn\"\\` in its\ncomponents is correct: cn is shadcn's own package (lib/utils re-exports it).\n\n**shadcn/ui works as-is.** Input, Textarea, Button and Label are styled native\nelements, so \\`name\\` does what it always does. Select, Checkbox, Switch and\nRadioGroup are Radix underneath and render a hidden native control whenever\ngiven a \\`name\\` - omit it and they are invisible to the form.\n\nDo NOT use shadcn's own Form/FormField/FormControl with this. Those wrap\nreact-hook-form, a different system for the same job. One or the other.\n\nA SEARCH ON THE PAGE IT IS ON: a GET form (action is a url string) whose action\nis the page the visitor is already on keeps that page on screen while the query\nruns - no shell is shown first, the old results stay, an input keeps what was\ntyped, and Back returns to the query it left. pending is true until the new\nresults are on screen:\n\n\\`\\`\\`tsx\n<Form action=\"/lookup\" method=\"get\">\n {({ pending }) => <><input name=\"q\" /><button>{pending ? 'Searching…' : 'Search'}</button></>}\n</Form>\n\\`\\`\\`\n\nTo show a skeleton where the results are instead of the old results, style them\nfrom the form's own data-pending attribute (the results stay OUTSIDE the form, no\nclient wrapper): section:has(form[data-pending]) #results { display: none }, and\nthe same selector showing a #results-skeleton.\n\nDo NOT patch around a skeleton on an in-page search with your own useState,\nrouter.replace or a Suspense key: the form already does this. It is the form\nonly - a <Link> or visit() to the same path with another query is a page of its\nown. A search to ANOTHER page still shows that page's shell first.`,\n },\n {\n topic: 'prefetch',\n summary: 'Making a navigation feel instant',\n body: `\\`<Link>\\` fetches its payload as it comes into view, on EVERY device\n(desktop too), when the browser is idle, as Next does - and again when the\nlink comes back into view or the tab is visible again, a request only once\nthe held payload has expired (no timer: an idle page fetches nothing) - the\nfirst 12 links in view per page (a payload is the whole page, 30 KB for a\nproduct with its related products; the rest wait for a touch or a settled\nhover) - the bytes only, held 30 s (per-visitor page) or 5 min (a page the build made,\nmarked public), and the eager <img>s the payload names are preloaded at low\npriority as it lands (the first 6 a page; not loading=\"lazy\"; nothing under\nSave-Data), so a product picture is decoded before the tap. Do NOT write an\nimage-prefetch route or an effect per card in the app. It is decoded - which\nloads the page's client chunks - on intent: a pointer settled 100 ms on the link, a mousedown, a touchstart,\neach 60-300 ms before the click. A click then finds a decoded tree and\nnothing on the network is between the click and the page: the SPA feel,\non a slow network too. A link to a guarded page (Sign in -> /agent ->\n/login) prefetches the redirect's destination too, so the tap asks for\nnothing. Save-Data turns the viewport prefetch off. To MEASURE a navigation\n(the number \"slow on my phone\" needs): every navigation leaves User Timing\nmarks rsc-kit:navigate:start / :decoded / :applied and a measure\nrsc-kit:navigate (click to the commit) - in the Performance panel, or\nperformance.getEntriesByName('rsc-kit:navigate') in a console; a gap\nbefore :decoded is network or chunks (the link was not on screen, or\nprefetch={false}), after :applied is the render. A tap BEFORE the runtime has\nhydrated is held by the bootstrap script and navigated to once the router\nis wired (a document load after 4 s if the runtime never comes) - so a slow\nphone's first tap is not a full reload. A form posting a server action\nsubmitted in that window is held and submitted again after hydration, so\n\"Add to cart\" tapped early is an action, not a full-page POST. Usually there is nothing to do; do\nNOT add a viewport observer, touch handler or pre-hydration click shim in\nthe app. A tag manager on the main thread (Facebook Pixel, Clarity) is the\nusual reason hydration is late: load those after the page is interactive.\nA <ViewTransition> around the page is the usual reason a navigation is\nslow on an iPhone (every iOS browser is WebKit): WebKit snapshots the\noutgoing page at its FULL height, a 12,000 px landing page is 44 megapixels\nat 3x, ~600-900 ms frozen per navigation and enough memory for iOS to\ndiscard the tab (which returns as a reload). A navigation here is an\ninstant swap, like Next - do NOT add a page fade in a port; a port that\nhas one should remove it. The transition worth having is a shared element\n(<ViewTransition name=...> on one image). If a page fade is wanted anyway,\n<PageTransition className=...> from @rsc-kit/core/PageTransition is the\none that skips WebKit (and reduced motion), and ships its own CSS - a\n120 ms cross-fade with pointer-events off on the overlay; nothing to add to\nthe stylesheet. Props: duration (ms, 0 = instant swap), webkit=\"run\" for a\none-screen page that measured fine. See read_guide view-transitions.\n\n\\`\\`\\`tsx\nimport Link from '@rsc-kit/core/Link'\n\n<Link href=\"/orders\">Orders</Link>\n<Link href=\"/orders\" prefetch={false}>Orders</Link> // opt out\n<Link href=\"/orders\" cacheFor={30_000}>Orders</Link> // hold the payload longer\n\\`\\`\\`\n\n\\`href\\` is typed to the routes the build found, so a link to a page that no\nlonger exists stops compiling - and a url the backend owns is one once it is\nnamed in backendPaths. Cast with \\`as Route\\` only when the destination is\ngenuinely computed.\n\nTo prefetch from code — a row about to be clicked, a wizard's next step:\n\n\\`\\`\\`ts\nimport { prefetch } from '@rsc-kit/core/router'\n\nprefetch('/orders/42')\nprefetch('/orders/42', undefined, { intent: true }) // and decode it now: the chunks too\n\\`\\`\\`\n\nWhat is prefetched is the RSC payload, not the html, so it is small and it warms\nthe same cache the navigation will read. Never the page on screen, and never a\npage still held behind it (the page just left): a navigation to that reveals\nit. After an action that revalidated, and after refresh(), every prefetched\npayload and every held page is dropped - they are from before the write - so\nvisit() to a list after creating a row fetches the list with the row in it.\nWhat an action re-rendered goes under the page it was invoked on: a tap that\nleft the page while the action was in flight is answered by fetching the page\nnow showing again, whole, never by putting the old page under the new url.\n\nThe client components a prefetched page renders are loaded (code only) as its\npayload lands - the first eight per page, bounded per document - so the first\nvisit to a page does not wait on a chunk after the page arrived. Nothing to\nconfigure. Serve over HTTP/2 (Cloudflare does by default): every client\ncomponent is its own small chunk.`,\n },\n {\n topic: 'validation',\n summary: 'Checking input — forms, actions, urls and request bodies',\n body: `One contract everywhere: any Standard Schema (Zod, Valibot, ArkType).\n\n**Actions** validate on arrival and RETURN their failures, because React strips\na thrown message in production:\n\n\\`\\`\\`ts\nexport const createPost = client.input(schema).handler(async ({ input, ctx }) => …)\n\\`\\`\\`\n\n**Urls** validate by exporting a schema beside the page or route:\n\n\\`\\`\\`ts\nexport const params = z.object({ slug: z.string().min(1) })\nexport const searchParams = z.object({ page: z.coerce.number().int().min(1).default(1) })\n\\`\\`\\`\n\nValues arrive parsed and typed — \\`?page=3\\` is the number 3, a missing one is\nthe default. Never hand-parse \\`Number(searchParams.get('page'))\\`.\n\nThe same schema types every LINK to that page. Write search params as an\nobject, never as a string:\n\n\\`\\`\\`tsx\n<Link href=\"/search\" search={{ q: 'shoes', page: 2 }}>…</Link> // typed by the page's schema\nvisit(href('/search', { q: 'shoes' })) // same check, as a string\n\\`\\`\\`\n\nA key the page never reads, or a number written as text, does not compile;\na key the page requires is required on the link. A page with no schema takes\nany scalars. Do NOT build \\`?q=\\${q}\\` by hand when the page has a schema.\n\n**Api route bodies** the same way:\n\n\\`\\`\\`ts\nexport const body = z.object({ title: z.string().min(1) })\n\nexport async function POST(request: Request, { body }) {\n const { title } = await body\n}\n\\`\\`\\`\n\nThe failures answer differently on purpose:\n\n bad params 404 — the url does not describe a page\n bad searchParams the error boundary (400 for an api route)\n bad body 422, the status an action already uses\n\nA bad query is deliberately NOT a 404, or one bad link makes a real page look\ndeleted.`,\n },\n {\n topic: 'action-client',\n summary: 'Middleware for server actions, so a check cannot be forgotten',\n body: `\\`\\`\\`ts title=\"src/server/client.ts\"\n'use server'\nimport { createActionClient } from '@rsc-kit/core/action'\n\nexport const client = createActionClient({ onError: report })\n .use(async ({ next }) => {\n const user = await currentUser()\n\n if (!user) throw new ServerAuthenticationError()\n\n return next({ ctx: { user } })\n })\n\\`\\`\\`\n\n\\`\\`\\`ts title=\"src/server/posts.ts\"\n'use server'\nimport { client } from './client'\n\nexport const createPost = client.input(schema).handler(async ({ input, ctx }) => …)\nexport const getPosts = client.query(async ({ ctx }) => …)\n\\`\\`\\`\n\n\\`.handler()\\` is a mutation (POST). \\`.query()\\` is a read (GET). Both run the\nchain, so \\`ctx.user\\` is typed and non-null inside them.\n\nFor a failure the schema cannot know - an account not found, a slug taken -\nthe handler is given \\`fieldErrors\\`, typed to its own input so a field the\nschema does not have is a compile error:\n\n\\`\\`\\`ts\n.handler(async ({ input, fieldErrors }) => {\n if (!account) return fieldErrors({ email: 'Account not found' })\n})\n\\`\\`\\`\n\nWRITE return fieldErrors(...). It throws either way, but TypeScript cannot see\na never-return through a destructured argument, so without the return the\nvalue you checked stays possibly-undefined on the next line. It lands in\nvalidationErrors on that field, the same place a schema refusal does. This is\nnext-safe-action's returnValidationErrors with no schema argument and no\n_errors nesting.\n\nA plain \"use server\" function with no action client imports the same thing,\nuntyped, from '@rsc-kit/core/action'. Same rule: return fieldErrors(...). <Form>\nshows the fields as error('name') and the \"\" key as formError, and useAction\nreturns them as validationErrors; AWAITED DIRECTLY it rejects with\nServerValidationError (@rsc-kit/core/errors: .errors, .fieldErrors, .formErrors)\n- as does a backend stub whose input was refused - so code after the await does\nnot take a refused input for its result. One rule for a stub: anything that did\nnot happen REJECTS (ActionRefusedError, ServerValidationError); only a redirect\nresolves ({ redirected }), and isRedirected is the one check on a resolved value.\n\nWhen the INPUT is fine and the answer is still no - a project with orders\nattached, a plan without the feature - that is a refusal, not a field error.\nUse refuse(message, data), and declare the data's shape with .refusal(schema):\n\n\\`\\`\\`ts\n.refusal(z.object({ blockers: z.array(z.object({ id: z.number(), href: z.string() })) }))\n.handler(async ({ input, refuse }) => {\n if (attached.length) return refuse('Still in use', { blockers: attached.map(toLink) })\n})\n\\`\\`\\`\n\nThe message is result.serverError (a form's formError) and is never replaced\nby onError's generic message. The data is result.refusal, typed from the\nschema and checked against it (a form's formRefusal, typed from the form's\naction - no cast; a component below the form uses useForm<Values, Refusal>(). Do NOT encode blockers into the message or into\nfieldErrors({ '': ... }) - the links are lost. Do NOT re-derive them from the\npage's own list - a refusal decided at the moment of the write knows what is\nblocking it now. A backend refuses the same way (Laravel Rsc::refuse(), Go\nRefuseWith) and arrives as the same result.refusal. Data from an action with\nno .refusal(schema) is not sent. Through a generated stub (<Form\naction={ordersDelete}>) only the MESSAGE arrives, as formError: a plain\nfunction has no .refusal(schema), so the data is left out and the log says so -\nto act on it, call rpc() from a createActionClient action with .refusal(schema).\nAwaited DIRECTLY, a stub REJECTS with ActionRefusedError (@rsc-kit/core/errors:\n.message, .status), so code after the await does not run for a refused write -\nonly a redirect resolves. <Form> shows it as formError, useAction as serverError.\nA query built with .input(schema) takes that schema's input (a wrong key fails\ntsc); a query cannot redirect - throw ServerAuthenticationError (401) instead. Any backend refusal with a status, data or\nnot (Go Refuse(503, \"busy\"), Laravel abort(429)), and a backend's 401, 403 or\n404, arrive as result.serverError with the backend's message - never onError's.\nThrough a generated stub they are formError, and awaited directly an\nActionRefusedError with .status 401, 403 or 404 - a backend 404 in an action\nis its message, never a 500 (an action has no not-found page).\nDo NOT write an onError that unwraps those; only a real failure reaches it. A\n.query() read rejects with the same message, and fetchQuery's error carries\n.status and .refusal.\n\nThe point is not convenience. An action cannot be added without the check,\nbecause there is no other constructor to reach for.\n\nStack clients for a narrower rule:\n\n\\`\\`\\`ts\nexport const admin = client.use(async ({ ctx, next }) => {\n if (!ctx.user.isAdmin) throw new ServerAuthorizationError()\n return next({ ctx })\n})\n\\`\\`\\`\n\nRoute \\`middleware.ts\\` does NOT run for actions — an action renders no route.\nThat is why the check goes here.\n\nThe built action is typed by its schema: createPost({ title: 1 }) fails the\ntypecheck; FormData is accepted beside it (how <Form> calls it). It always\nresolves to an object - data | validationErrors | serverError | redirected.\n\nFrom a button, not a form - useAction (next-safe-action's useAction, and its\nuseOptimisticAction through React's useOptimistic):\n\n 'use client'\n import { useAction } from '@rsc-kit/core/useAction'\n const [shown, hide] = useOptimistic(posts, (list, id: number) => list.filter((p) => p.id !== id))\n const { execute, executeAsync, isPending, status, result, reset } = useAction(archivePost, {\n optimistic: ({ id }) => hide(id), // inside the transition; React reverts it on failure\n onSuccess: (data) => …, // typed from the handler\n onError: (message, result) => toast.error(message), // serverError, or the first validation message\n onSettled: (result) => …,\n })\n <button disabled={isPending} onClick={() => execute({ id })}>\n\nexecute never throws (a thrown failure becomes serverError); a redirect runs no\ncallback and sets no state; execute keeps one identity and reads the newest\ncallbacks.\n\nAn action body - arguments and any uploaded files, read whole - is capped at\n8 MB; over it the answer is 413 before a byte is kept. rscKit({ maxActionBody })\nraises it. For large files, mint a pre-signed url and upload straight to storage.`,\n },\n {\n topic: 'data',\n summary: 'Loading data, streaming it, and when the browser needs to refetch',\n body: `**In a server component, just await it.** No loader, no getServerSideProps.\n\n\\`\\`\\`tsx\nexport default async function Page() {\n const posts = await db.posts.all()\n}\n\\`\\`\\`\n\n**Better: do not await.** Pass the promise down and let a client component\nresolve it — the shell paints at once and the rows stream into the same\nresponse, with no request from the browser:\n\n\\`\\`\\`tsx\nexport default function Page() {\n const posts = getPosts() // not awaited\n\n return (\n <Suspense fallback={<Skeleton />}>\n <List posts={posts} /> {/* 'use client': use(posts) */}\n </Suspense>\n )\n}\n\\`\\`\\`\n\nReach for this first. It is the thing RSC is for.\n\n**When the BROWSER decides to refetch** — a filter, another page, a refresh —\ncall fetchQuery. It is a plain async function that returns the typed answer;\nNO library is needed:\n\n\\`\\`\\`tsx\nconst [listings, setListings] = useState(initial) // the server-rendered value\nconst [pending, start] = useTransition()\nconst show = (kind) => start(async () => setListings(await fetchQuery(getListings, [kind])))\n\\`\\`\\`\nWorks in an onClick, onSubmit, useEffect - anywhere in the browser.\n\n**When you want CACHING** (stale-while-revalidate, dedupe, offline), hand the\nsame call to the library that holds the answer; this package ships none:\n\\`\\`\\`tsx\nuseQuery({ queryKey: ['posts', kind], queryFn: () => fetchQuery(getPosts, [kind]) })\nuseSWR(['posts', kind], () => fetchQuery(getPosts, [kind]))\n\\`\\`\\`\nfetchQuery sends the read as a GET and goes to the server every time, which\nis what a fetcher needs. It will never cache, dedupe or batch: a client\ncache is the library's job, and a batch would lose the per-read cache key a\nGET has. The ladder, most reads stopping on the first rung:\n 1. the value now: fetchQuery + setState\n 2. survive a reload / let a CDN serve it: query(fn, { cache: 'public', maxAge })\n - HTTP caching, no code in the page\n 3. staleness, background refresh, optimistic updates, shared across\n components: TanStack or SWR with fetchQuery as the fetcher\nDo not add a cache on top of it, and do not install TanStack for a single\nbutton that reads once.\n\nKeep the arrow: TanStack calls a bare \\`queryFn\\` with its own context, and a\nserver function serialises whatever it is handed.\n\nA value that keeps CHANGING while someone watches - polling, SSE, realtime -\nis how_to live-data, not this.`,\n },\n {\n topic: 'suspense',\n summary: 'Where boundaries go, and why the build cares',\n body: `A boundary is what lets a page be stored with a hole in it rather than not\nstored at all.\n\n\\`\\`\\`tsx\n<Suspense fallback={<Skeleton />}>\n <Slow />\n</Suspense>\n\\`\\`\\`\n\nTHE DEFAULT PATTERN - write pages this way: the page is a SYNCHRONOUS\ncomponent; fixed copy, titles and frames go straight in it (they are the\nstored shell); each read is its own async \"slot\" component under its own\n<Suspense>, with a skeleton the shape of what it replaces:\n\n\\`\\`\\`tsx\nexport default function ShopifyPage() { // not async\n return (\n <Card>\n <CardTitle>Shopify</CardTitle> {/* in the shell */}\n <Suspense><ConnectButtonSlot /></Suspense> {/* nothing until known */}\n <Suspense fallback={<ConnectionsSkeleton rows={1} />}>\n <ConnectionSlot /> {/* async: its own read + check */}\n </Suspense>\n </Card>\n )\n}\n\\`\\`\\`\n\nEach slot does its own read and its own permission check, so a fast part is\nnot held back by a slow one, and a slot can move or be revalidated as a\nsection on its own.\n\nloading.tsx wraps the WHOLE page in one boundary. Use it only for a page that\nis one read and nothing else (a detail page whose skeleton is the page). Do\nNOT reach for it by default:\n- the heading/copy/frames the page already knows wait with the data;\n- the slowest read holds back every other part;\n- notFound()/redirect() or a backend 404 decided under it may already have a\n 200 on the wire for a person (the page then ends with a noindex tag; a\n crawler is answered with the finished page and gets the real 404) - a\n check that must be a 404 for everyone goes in middleware.ts, which runs\n before anything is sent;\n- it sits BELOW its layout: a layout that awaits (params, session) needs its\n own <Suspense> inside the layout;\n- a ROOT loading.tsx hides pages waiting in the wrong place (the build only\n warns \"nothing painted without the root loading.tsx\").\n\nThe build renders every page. Whatever has not resolved when the budget expires\nbecomes the hole; everything above it is stored and served instantly. So a page\nwith no boundary above its slow part cannot be stored at all — the build says\nso:\n\n ƒ /orders\n blocks before anything can paint. Add a loading.tsx beside it, or put a\n <Suspense> above the waiting, and it has a skeleton to store.\n\nFix that by moving the read into a slot under <Suspense>, not by adding a\nloading.tsx.\n\nA painted fallback stays up at least 300 ms - React's rule: content that\narrives within 300 ms of the fallback painting waits until then. A stored\nshell's holes arrive a moment after the shell, so fast data shows its\nskeleton for the full 300 ms (a flash on every app launch). So: no boundary\naround data that is always fast; fallbacks shaped like the content (same\ncard, same size, numbers blank); no animate-pulse on short-lived fallbacks.\n\nA boundary does NOT fix a frozen \\`Date.now()\\`. Prerendering renders straight\nthrough a component that never awaits, so the value is captured exactly as\nbefore. A boundary becomes a hole only when something inside it waits.\n\nredirect() and notFound() inside a boundary still work: the shell has gone\nout, so the status stays 200 for a person - a late notFound() ends the page\nwith <meta name=\"robots\" content=\"noindex\"> - while a search engine or\nlink-preview crawler (matched by user agent) is answered once the whole page\nhas rendered and gets the real 404 or redirect. For a person the redirect\ntravels in the row's error digest and the browser\nperforms it as a navigation, layouts kept. A destination that is not this app's\nis a DOCUMENT LOAD, wherever the redirect is called: redirect('https://other.site/..')\ntype-checks as written, and so does a url the backend owns once it is named in\nbackendPaths (see the backend recipe) - no \\`as Route\\`. The url that redirected\nis replaced in history either way, so Back does not return to a page that sends\nyou out again. A late notFound() is the same: the browser asks for not-found.tsx and shows it where the page was, url unchanged.\nA not-found.tsx beside a layout answers notFound() from every page under it,\ninside that layout - the nearest one above the page wins, like error.tsx, so a\nsection keeps its header on a 404 (app/(shop)/not-found.tsx under\napp/(shop)/layout.tsx). A url no route owns is answered by the ROOT one only.\nerror.tsx's reset() ASKS THE SERVER AGAIN for the page (layouts kept) and shows\nwhat it answers, so a \"Try again\" button recovers from a failure that has\npassed; it is the whole retry - no refresh() beside it, no location.reload().\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\nFOR AN EDGE THAT CACHES PAGES: rsc-kit implements the edge contract (v2,\ndocs: reference/edge-contract). Every server build writes .output/edge.json -\n{ v: 2, build, shells, static, bypass } - whose shells are url patterns (\"/\",\n\"/[team]/[app]\", \"/docs/[...slug]\"), the ◐ pages, and whose static are\nconcrete paths, the ○ pages at the path each was rendered at: both less every\npage with middleware (middleware.ts or the host's export const middleware) or\nbound to the host, at most 200 each, [] when there are none. A root\nmiddleware.ts - where security headers usually go - is above every page and\nempties both lists; the build names the middleware that kept pages out.\nHeaders fixed for every page go in rscKit({ headers: { 'X-Frame-Options':\n'DENY' } }) instead: set on every response, sent to the edge as the page's\nheaders, and the page stays listed. Only the contract's allow list is\naccepted (no Cache-Control or Link); anything else is a build error. GET\n/_edge/page?path= answers a shell, or a static page whole with complete: true;\nPOST /_edge/resume?url= answers a shell's holes. Both answer only a request\nwhose Edge-Token header holds the EDGE_TOKEN env var; unset, both are a 404. A\nstatic page's payload is copied to /assets/_rsc/<hash>.rsc (immutable) and\nnamed in its document, so it boots with no request to the origin. build covers\nthe client bundle and every stored page's bytes, so a server-only change is a\nnew build and an edge drops what it cached; it is not X-RSC-Version, and open\ntabs do not reload for it. There is no v1 (ppr.json, /_ppr/*, PPR_TOKEN) any\nmore. Do NOT derive the lists from .output/server/rsc-static/: a guarded page\nhas a file there too, and _team_ cannot say whether it was a parameter.`,\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. The build names the case that BREAKS: \"N\n server files reach client-only React: <file> (<package>: useState)\" - a\n server file whose code, or a library's under it, reaches a hook the server\n lacks; a render calling it fails \"(0, ...useState) is not a function\". Add\n \"use client\" to that file. Icons and packages that ship their own\n directive are not named.\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 Go type with its own MarshalJSON is unknown in TypeScript - do NOT write a\nnarrowing helper in the page; give the type a JSONSchema method (rsc-kit/go v0.11.0+):\nfunc (Money) JSONSchema() rsckit.Schema { return rsckit.Schema{\"type\": \"string\"} }\n(keywords the build reads: type, items, properties/required, additionalProperties, enum, anyOf).\nA backend FAILURE in development carries its trace: the thrown error's cause is\n\"Backend <Type>\" with the PHP/Go frames, and the stack says \"Caused in the backend\nby ...\" - read that before guessing (Laravel: app.debug; Go: CallbackHandler.Debug\nor RSC_DEBUG=1). Never in production.\nA failed rpc() in a server component: catch and check\nerror instanceof ServerAuthenticationError (401) / ServerAuthorizationError (403)\nfrom '@rsc-kit/core/errors'; rethrow anything else.\nreg.Register(name, func(ctx, args rsckit.Args) (any, error)) stays untyped.\nLaravel: rsc:host-manifest types each app/Rsc method from its PHP signature -\nint/float/string/bool params (nullable, defaults optional, variadic), a\nFormRequest first param from its rules(), and a result that is a scalar, a\nbacked enum, or a plain class with public typed properties (a data object ->\nan interface). array/Model/Collection/JsonSerializable stay unknown: give\nthose rpc<T>(). Return a data object, not an array, to get a typed result. Tests\nanswer the backend with createTestApp({ host }) - how_to testing.\n\nWriting or changing an ADAPTER: it must pass the conformance suite before it\nships - register the Conformance.* functions (read_guide your-own-backend,\n\"Before you ship\"), serve them, and run\nnpx -y -p @rsc-kit/core rsc-kit-conformance --endpoint <url>/__rsc/host-call --secret <s> --manifest rsc-host.json\n(Go: cmd/conformance; Laravel: tests/Conformance are fixtures to copy).\nBAP vs Inertia for a Laravel team: read_guide bap-vs-inertia.\n\nA backend in another language answers that ONE endpoint, POST /__rsc/host-call,\nand the renderer wires itself from two variables in .env: RSC_BACKEND (a\nLaravel app's APP_URL counts) and RSC_HOST_CALL_SECRET. Both or neither.\n\nA url the route tree does not own goes to the backend (/login, a webhook, an\nOAuth callback). One that it DOES match does not: a dynamic root route such as\n[team]/[app] matches every two-segment url, renders, says notFound() - and a page\nthat said that is never forwarded, so the backend's /gitlab/connect was the\napp's 404 (and sign-in looped) - Laravel's /login and /auth/* as much as Go's\nwhen the renderer is in front. Name the backend's prefixes, and they are\nforwarded BEFORE any page is asked: rscKit({ backendPaths: ['/auth', '/github',\n'/gitlab'] }). A prefix at a segment boundary (/auth, not /authors), every\nmethod; '/' and /_rsc are refused; a team called auth is then unreachable. Do NOT\nwrite catch-all route.ts files that forward by hand: that copies the forwarding.\nA named prefix is also a DOCUMENT LOAD in the browser, never a page: a Link,\nvisit() or redirect('/gitlab/connect') to it loads the document and is\nnever prefetched, so the backend route may bounce on to another site (an OAuth\nstart). NOT named, the router fetches it as a payload, the bounce to another\norigin is a hop a fetch cannot make, and the tap does nothing. Do NOT work around\nit with a client component that sets window.location.\n\nLaravel: composer require rsc-kit/laravel, then php artisan rsc:install. It\nruns rsc-kit init, which writes ONE vite.config.ts (laravel-vite-plugin is\nmoved aside - the renderer owns the frontend). Source is resources/js (the route tree is resources/js/app).\n\nrpc() is a global the renderer installs in its own process (never imported,\nnever in the browser bundle): one POST to the backend's host-call endpoint\nwith { function, args }, the secret and the visitor's cookie; the answer is\nthe return value as JSON, typed by rpc<T>(). Refusals arrive as their kind\n(422/401/403/redirect, or the status the backend gave: abort(429, 'Slow down')\nkeeps its 429 and its message), never a 500; sibling calls in one tick are batched\nand each resolves the moment the backend answers it.\nA BAP server bundle carries no database driver, ORM or auth library - the\nbackend owns those.\n\nReach PHP from a server component - rpc() is a global, typed in\n.rsc-kit/rsc-env.d.ts, server render only:\n\n // app/Rsc/Orders.php: public function recent(int $limit): array\n const orders = await rpc<Order[]>('Orders.recent', 5)\n\nThe call runs AS THE VISITOR (their cookie is forwarded; auth()->user() is\nthem). Refuse with attributes: #[Authenticated], #[Can('update', Order::class)],\n#[Middleware('throttle:60,1')]. A ValidationException lands on the form as\nvalidationErrors; Authentication/Authorization exceptions answer 401/403. An\naction shows an abort()'s message to the visitor as written, so give it one -\nabort(429, 'Slow down'); without one the form says \"Refused.\". Only a failure\nis replaced by onError's generic message.\n\nGuard a route in Laravel's vocabulary, no route declared in PHP:\n\n // resources/js/app/admin/middleware.ts\n export const middleware = ['auth', 'verified', 'can:update,post']\n\nMake one: php artisan make:rsc-action Orders --method=cancel --auth\n--can=update,Order --middleware=throttle:60,1 --revalidate=orders (--rpc for\nan rpc() class under app/Rsc; no --method = invokable; a slash nests,\nBilling/Invoices). Do NOT hand-write the attributes from memory; the command\nwrites the ones the registry reads, and it writes the manifest\n(rsc-host.json) too - a running dev server restarts on its own when it\nchanges, so the export is importable when the command returns.\nServer actions are classes in app/Rsc/Actions, found by reflection in PHP;\n\\`php artisan rsc:host-manifest\\` writes rsc-host.json - { actions,\nfunctions } - and vite.config.ts runs it as dev and every build start:\nrscKit({ hostManifest: { command: ['php', 'artisan', 'rsc:host-manifest'] } }).\nfunctions types rpc()'s first argument, so a misspelt name fails tsc. The\nbuild writes the \"use server\" stubs from actions - import ordersCancel from\nthe generated actions module in a client component. Rsc::revalidate('orders')\nin the action returns the re-rendered region with the answer.\n\nPer url: if the React tree has it, React renders it, otherwise Laravel does\n- including / : the welcome route in routes/web.php answers nothing while\nresources/js/app/page.tsx exists (the package registers the tree's urls from\nbootstrap/rsc/vite/routes.json after routes/web.php). Do NOT tell the user\nto delete the welcome route to make the page show; do not add Laravel routes\nfor React pages.\n\nphp artisan serve is one worker, which deadlocks the proxy - unless\nPHP_CLI_SERVER_WORKERS=4 in .env AND serve --no-reload (Laravel ignores\nthe variable otherwise). Herd, Valet, FPM, Octane are fine as they are. Production: put the renderer in front (bun\n.output/server/index.mjs with the app's .env), restrict /__rsc/host-call at\nthe web server.\n\nAny other language implements the same endpoint - request { function, args },\nreply { result | validationErrors | unauthenticated | unauthorized | redirect\n| error, revalidate }, answers '__rsc.middleware' with true or a refusal, and\na batch { calls: [...] } as application/x-ndjson - one line per call AS IT\nFINISHES, { index, status, ...reply }, in any order, flushed each time\n(X-Accel-Buffering: no) - or, less good, one JSON { replies: [...] } in\norder (every call then waits for the slowest). Calls issued in the same\nrender tick travel as one batch, so parallel reads are one backend request\nand a fast read still resolves while a slow sibling runs. Batches are\nrequired: any other answer fails every call in it (at most 50 per batch;\n413 for more is fine).\n\nFull guides: read_guide({ slug: 'backend-answered-pages' }), read_guide({ slug: 'laravel' }), read_guide({ slug: 'go' }), read_guide({ slug: 'your-own-backend' }).`,\n },\n {\n topic: 'startup',\n summary: 'Once-per-process setup and teardown, and request logging - src/instrumentation.ts is imported before any page, its register() awaited before the first request, its shutdown() called when the server stops, its onRequest() once each response is over',\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\nRequest logging: export onRequest() from the same file, and the engine calls it\nonce per request it answers (page, payload, action, route.ts) when the response\nis OVER - the last byte sent, or the visitor gone mid-stream:\n\n export const onRequestHeaders = ['cf-ray'] // the only headers it is given\n export function onRequest({ method, pathname, status, durationMs, aborted, error, headers }: ServedRequest) {}\n // ServedRequest from '@rsc-kit/core/request'\n\ndurationMs runs to the last byte; aborted is true when the visitor left\nmid-stream; error is set when the render failed. pathname has NO query string.\nDo NOT log from middleware.ts for this (it runs before the render, so it never\nsees the end), and do NOT wrap the server entry - it is generated. onRequest()\nis not awaited, and a throw from it never fails the page.\n\nA file in src/commands (how_to commands) does NOT import this one unless it\nexports \\`register = true\\` (the literal): without it the command runs from a\nbundle that leaves instrumentation.ts out, so none of its top level - env\nvalidation included - runs for it. With it, the file is imported and\nregister() runs first.\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 and its 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 and each cause on a [cause]: line (Drizzle's failed\nquery keeps the database's reason there - do NOT catch to re-log it), any other\nreturn (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's\ncode, but NOT instrumentation.ts unless it exports \\`register = true\\` (the\nliteral; the build reads it to pick the bundle): a queue drain should not fail\non the web's Stripe key. It validates what it imports - \\`import '@repo/env/worker'\\`\nin the command. With register = true, instrumentation.ts is imported (its env\nvalidated), register() runs first, and shutdown() after it returns, also after\nit 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.\nA compiled binary (bun run compile) carries those packages and writes them to\ndisk on its first start, to $RSC_KIT_RUNTIME_DIR/<hash>/ (default: beside the\nbinary), and loads them from there - so the binary needs a WRITABLE directory:\non a read-only filesystem set RSC_KIT_RUNTIME_DIR (e.g. /tmp/app). One\ndirectory is safe to share: a later start writes nothing, two at once do not\nclash, and each version has its own hash folder (one untouched for a day is\nremoved). What goes to disk: packages with a .node/.wasm (or os/cpu), packages\ndepending on them, their dependencies, and anything in serverExternalPackages\n(name a pure-JS package there when it reads files beside its own code); pure JS\nstays bundled. Compiling for another platform (--target=bun-linux-x64 on a Mac)\nneeds that platform's packages installed beside the build machine's:\nbun install --os=darwin --os=linux --cpu=arm64 --cpu=x64. Without them\n\\`bun run compile\\` (and compile:binary after that build) fails and names them;\ndo NOT work around it by dropping --target or copying node_modules next to the binary.\nThe compile step is two scripts: compile is \"vite build && bun run compile:binary\",\ncompile:binary is the bun build --compile alone, and .output/rsc-kit.json names\ncompile:binary so a deploy that has just built does not build again. Keep both;\ndo NOT put vite build back inside compile:binary.\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). A refresh is swapped in as a\ntransition: the section keeps what it showed until the new content is ready,\nnever its skeleton, so do NOT add a cache or a held copy to avoid the blink.\nWhat a refresh rendered lasts\nas long as the layout: a visit to another page under it keeps it, so a rename's\nnew name stays in the frame after the form navigates. The active tab is usePathname()\nin a client component, not a refresh. Do NOT draw the header in each tab's page\ninside its Suspense: a tab switch mounts a new page, and its fallback replaces\nthe header until the data arrives.\nPARENT AND CHILD - a change to a child changes its parent's list: say both\nwhere the write is - changed(\\`app:\\${id}\\`, \\`team:\\${teamId}:apps\\`) - and let each\nregion name only what it shows. Never make a list watch every child's name.\nNOTHING ANNOUNCES IT (a host's logs, a third-party status) - usePolling, not refreshOn.\nTEST IT - (await createTestApp()).watched('/teams/acme') -> { page: [...], apps: [...] },\nthe names each region is handed; [] means refreshOn gave none (also warned in dev).\nSAY IT CHANGED - wherever the change happens, not only in an action:\n Go: reg.Changed(ctx, \"team:\"+teamID+\":repos\") // the webhook handler, a job\n Laravel: Rsc::changed(\"team:$teamId:repos\"); // a controller, a job, a listener\n JS only: import { changed } from '@rsc-kit/core/changed'; await changed(\\`team:\\${team}:repos\\`)\nANY DATABASE - rsc-kit ships no driver; hand it yours. Postgres:\npostgresVersions(sql) (postgres.js/Bun.sql, or Drizzle's db.$client) - one\nstatement per change, instant via LISTEN. MySQL/MariaDB: sqlVersions({\ndialect: 'mysql', query: async (t, p) => (await pool.query(t, p))[0] })\n(mysql2). SQLite: sqlVersions({ dialect: 'sqlite', query: async (t, p) =>\ndb.query(t).all(...p) }). libSQL/Turso: sqlVersions({ dialect: 'sqlite',\nquery: async (sql, args) => (await client.execute({ sql, args })).rows }).\nAlways pass dialect: without it a change is a read + write per name.\nRedis/ORM query builders: createVersions({ read, bump, listen?, notify? }).\nInstant across instances: postgres.js LISTEN, Redis pub/sub; others within ~1s.\nNO BACKEND (JS only) - changed() works as-is on one server. With more than\none instance, or a worker process that finishes jobs, keep versions in a\ntable they all share: installVersionSource(postgresVersions(sql)) in\ninstrumentation.ts register() AND in the worker (postgres.js LISTENs, so\ncross-instance changes are instant; so does Bun.sql (Bun 1.4+); any SQL\ndriver/ORM: sqlVersions({ query }) - Prisma: query: (t, p) =>\nprisma.$queryRawUnsafe(t, ...p); anything else (Drizzle query builder, Redis,\nKV): createVersions({ read(names), bump(names), listen?, notify? }) and\nrsc-kit does the waiting/waking). An app-installed store always wins over a\nbackend's. In-memory versions fail with 2+ instances; in production rsc-kit logs a\nwarning once when it falls back to them - installVersionSource(memoryVersions())\nsays \"one instance on purpose\" and silences it.\nA DIRECT DATABASE EDIT (psql, a migration, an admin tool, another app) says\nNOTHING: nothing watches tables, so open tabs stay stale until refreshed (a reload\nreads fresh - the data is not wrong). Make the WRITER call changed() after the\ncommit; or put a TRIGGER on the data table that upserts the rsc_versions row in\nthe same transaction (moves with the commit, a rollback moves nothing) and, on\nPostgres, pg_notify('rsc_versions', '') so tabs hear at once - without it up to\n30s with a listening store, 1s without; the name must be spelt as refreshOn\nspells it. usePolling only where nothing can say it.\nTable: rsc_versions(name TEXT PRIMARY KEY, version BIGINT NOT NULL) - same as\nGo's SQLVersions. ANY process can bump without rsc-kit, with one upsert that\nmoves version to GREATEST(version + 1, now in ms) - Postgres:\nINSERT ... VALUES (name, (EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint)\nON CONFLICT (name) DO UPDATE SET version = GREATEST(rsc_versions.version + 1,\nEXCLUDED.version); then NOTIFY rsc_versions. MySQL: GREATEST(version + 1,\nVALUES(version)) with FLOOR(UNIX_TIMESTAMP(NOW(3)) * 1000); SQLite: MAX(...)\nwith CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER).\nCLEANUP - a version is the time a name last changed, so deleting old rows is\nalways safe (a tab holding one refreshes once; it never comes back at a value\na tab holds): versions.prune() on sqlVersions/postgresVersions (30 days), or\nDELETE FROM rsc_versions WHERE version < <ms 30 days ago> from a cron.\nmemoryVersions forgets after 30 days; the server forgets names no tab\nwatches. Go: store.Prune(ctx, 0) on SQLVersions/MemoryVersions. Laravel:\ncache keys expire after RSC_VERSIONS_KEEP_DAYS (30); with\nRSC_VERSIONS=database schedule php artisan rsc:prune-versions daily. A custom store's bump MUST use nextVersion(current) - never + 1, which repeats once a row is pruned.\nGO, SEVERAL INSTANCES - SQLVersions is noticed within ChangedPoll (1s). For\nat once: SQLVersions{..., Notify: \"rsc_versions\"} plus reg.WakeOn(ctx,\nlistener) where the listener (pgx: LISTEN rsc_versions, WaitForNotification)\ncalls connected() then wake() per notification. One instance needs neither.\nWORKERS, ONE HUB - by default each isolate watches its own tabs: one ask per\ntab every 2s. nitro({ preset: 'cloudflare_durable' }) plus wrangler.jsonc\ndurable_objects binding \"$DurableObject\" (class \"$DurableObject\", migration\nnew_sqlite_classes) and vars RSC_CHANGES_HUB: \"$DurableObject\" sends every\ntab's stream to one Durable Object: one ask for all tabs, changed() in any\nisolate wakes it at once (instant even on D1), one wakeOn/broadcast\nconnection. Versions still live in the shared store (D1, backend). Costs\nDurable Object active time while tabs watch; opt-in, never detected.\nTABLE - the app owns rsc_versions (rsc-kit never creates/alters it at\nruntime): name TEXT PRIMARY KEY (MySQL: VARCHAR(255)), version BIGINT NOT\nNULL. Extra columns fine if they have defaults. Drizzle: a pgTable in your\nschema + createVersions with the query builder (not raw SQL).\nPOOLERS - a pooler in TRANSACTION mode (PgBouncer; DigitalOcean/Supabase/Neon\npooled URLs) silently drops LISTEN. postgresVersions probes once as it starts\nlistening; if deaf it logs why and reads every second. Fix:\npostgresVersions(pool, { listenWith: directOrSessionModeClient }) - one extra\nconnection per process; reads/writes stay on the pool. (await connection() is\nunrelated: it is about rendering per request.)\nPOLL ON PURPOSE - postgresVersions(pool, { listen: false }) when direct\nconnections are scarce: no listener, no probe, no warning; one indexed read per\nsecond per process for all its tabs; other instances' changes within ~1s.\nWORKERS / SERVERLESS - the stream is stateless (signed names) so it runs where\nresponses stream, but: each isolate/instance watches for its own tabs, so\nin-memory versions are useless - use a shared store. Platform request limits\ncut streams (they reconnect and catch up); billed-per-second functions pay for\nevery watching tab; Hyperdrive/serverless drivers can't LISTEN (probe falls back\nto 1s, or listen: false). No streaming at all: usePolling.\nCLOUDFLARE WORKERS + D1 (verified on wrangler dev; examples/cloudflare /live):\n // src/versions.ts - import the binding INSIDE the query: the build prerenders\n // in Node, runs register(), and cannot load 'cloudflare:workers' (a top-level\n // import breaks the build).\n const db = async () => (await import('cloudflare:workers')).env.DB\n export const versions = () => sqlVersions({ dialect: 'sqlite',\n query: async (t, p) => (await (await db()).prepare(t).bind(...p).all()).results })\n // src/instrumentation.ts: installVersionSource(versions)\n // wrangler.jsonc (Nitro merges it): \"d1_databases\": [{ \"binding\": \"DB\",\n // \"database_name\": \"my-app\", \"database_id\": \"<id>\", \"migrations_dir\": \"migrations\" }]\n // migrations/0001_rsc_versions.sql:\n // CREATE TABLE IF NOT EXISTS rsc_versions (name TEXT PRIMARY KEY, version BIGINT NOT NULL);\n // npx wrangler d1 migrations apply DB --remote (--local for wrangler dev)\n // npx wrangler secret put RSC_SIGNING_SECRET (.dev.vars for wrangler dev)\n // A section reading D1 itself: await connection() first - D1 is per request.\nINSTALL LAZILY - installVersionSource(() => postgresVersions(client)): a\nfactory, made on first use. register() also runs in the build, often without\nDB settings; a store made there makes its client there.\nLISTEN THROUGH AN ORM - pass the driver's listen/notify to createVersions:\nDrizzle on Bun.sql: listen: wake => client.listen('rsc_versions', wake),\nnotify: () => client.notify('rsc_versions', '') with the SQL client you gave\ndrizzle(). node-postgres: a dedicated pg.Client, LISTEN rsc_versions, and\non('notification', wake). A listening store reads the table only as a safety\nnet (~every 5s per server), not every second. NOTIFY inside a transaction is\ndelivered on commit, so it keeps the after-commit rule for you.\nAFTER COMMIT - call changed() AFTER the transaction resolves, never inside\nit: a tab refreshes at once and would read the old data, then sit at the new\nversion. Laravel: DB::afterCommit(fn () => Rsc::changed(...)).\nSAME NAME - many sections/pages may refresh on one name; one changed() moves\nit once and every tab refreshes what it shows. Section names are per page.\nSTARTUP - declare RSC_SIGNING_SECRET in src/env.ts like every other variable,\nrequired in production (z.string().min(32) when NODE_ENV is production): the\ndeploy then fails before taking traffic. No env.ts: assertSigningSecret() from\n@rsc-kit/core/changed in register() does the same. Otherwise rsc-kit refuses\non the first request.\nSECRET - set RSC_SIGNING_SECRET, the same on every instance, with or without\na backend (NOT the host-call secret). In production an app using refreshOn\nrefuses to serve without it. Names are signed per request, never at build.\nREQUEST - a refreshOn function gets params and searchParams ALREADY AWAITED\n(({ params }) => [\\`team:\\${params.team}\\`] is right), and runs per request:\ncookies()/headers() work, e.g. a name for the signed-in user.\nISOLATION - a name is a signal, not data or a permission: a refresh renders\nfor that tab's visitor through their own session and guards, so nobody sees\nanother's data through a name. A tab can only listen for names its page was\nrendered with (signed). Name the data narrowly with ids: conversation:{id},\ninbox:{userId}. No emails/secrets in names. Never shared: true on per-visitor\nsections.\nSHARED - section(name, C, { refreshOn, shared: true }) for a section that\nrenders the same for everyone allowed to see the page: tabs refreshing\nbecause of the same change share ONE render (guards still run per tab).\nNever on per-visitor content - rpc() runs with the first visitor's session.\nLARAVEL - keep the default (versions in the cache; the renderer asks Laravel\n~every 2s, ONE request per renderer process for all its tabs - negligible).\nLaravel stays the only thing talking to its database. RSC_VERSIONS=database\n(publish rsc-migrations) is for pruning or for writers outside Laravel; Laravel\nstill answers. INSTANT: Laravel broadcasting (any Pusher-protocol server:\nReverb, Pusher, Soketi) - RSC_BROADCAST=true in Laravel, RSC_BROADCAST_URL +\nRSC_BROADCAST_KEY for the renderer. Rsc::changed() announces a change (no\nnames - public channel), the renderer asks Laravel at once, else every 30s.\nNever have the renderer read Laravel's database.\nWAKE ANYTHING - wakeOn(listen) from @rsc-kit/core/changed: listenToBroadcast({\nurl, key }), or (wake) => subscriber.subscribe('rsc:changed', wake) for Redis.\nA wake carries nothing; the renderer then asks where versions live. Versions\nare still needed: they say WHICH names moved, per visitor, and let a\nreconnecting tab catch up.\nDEBUG - dev console lists what each region watches; a region missing there\nrendered no names (server log says why). Hidden tabs stop watching and catch\nup when shown.\nCOST - per visible tab one SSE connection (keepalive every 8s - set\nRSC_STREAM_KEEPALIVE_MS lower if a server/proxy drops idle connections sooner;\n~40 bytes\nper change); per change one refresh of just the sections showing the name;\nper server one versions read covering all its tabs.\nHOW - a name has a version, a number that moves when it is said to have\nchanged; nothing else travels. The page learns versions at render, one SSE\nstream per tab (only on a page with names) watches them, and a moved version\ncalls refresh(section) for exactly the regions holding the name. Versions\nlive where the backend keeps shared state (Laravel's cache, a table in Go\nvia reg.Versions(&rsckit.SQLVersions{...}), this process without a backend). Go\nholds the ask and answers the moment a name moves; Laravel (PHP-FPM) answers\nat once and the renderer asks again ~2s later. Runs on Workers/Vercel: the\nstream is stateless, names are signed by the renderer (a tab may only watch\nnames its page was rendered with).\nFROM NEXT - this replaces revalidateTag, and is not the same thing: Next's\ntags label Data Cache entries and revalidateTag() purges them (the NEXT\nrequest refetches; open tabs learn nothing). rsc-kit has no data cache -\nevery render is fresh - so refreshOn names what is on screen and changed()\nrefreshes every open tab now. Port: tags: ['posts'] on the fetch ->\nrefreshOn: ['posts'] on the section; revalidateTag('posts') -> changed('posts').\nWHEN - use names when the change comes from OUTSIDE the tab: webhooks, jobs,\nteammates. Use revalidate('orders') inside the action that made the change.\nUse usePolling for a value with no backend signal; useEvents for a stream of\nvalues (live-data).\nTEST - testChanges() from @rsc-kit/core/testing: host: { ...names.host };\nnames.changed('team:1:repos').\nA name's name is not secret, but what its version reveals is only \"something\nnamed this changed\"; do not put secrets in name names.`,\n },\n {\n topic: 'live-data',\n summary: 'A value that keeps changing - realtime, live updates: usePolling over a query, or server-sent events (SSE, streaming from a middleware.ts generator) with useEvents - both feed TanStack, SWR or setState. For a backend-side change (webhook, job) see refresh-on',\n body: `Neither is part of query() - a query answers once and is cacheable.\n\nPOLLING - start here when you have no change feed yet. Reuses the query,\ngoes through its Cache-Control (a CDN collapses many tabs into one origin\nread per interval), pauses when the tab is hidden, never overlaps two reads.\n\\`\\`\\`tsx\nimport { usePolling } from '@rsc-kit/core/usePolling'\nconst { data, status, refresh } = usePolling(() => fetchQuery(getSeats), { every: 2_000 })\n\\`\\`\\`\nUNTIL IT SETTLES - a job that ends. until(data) says the last read; onSettled\nfires once on it. The result is the DATA; what to\ndo on settling is the page's choice:\n // a server-rendered list, some jobs still running: re-render through the server\n usePolling(() => fetchQuery(jobStatus, [id]), { every: 2_000, enabled: !isTerminal(job), until: isTerminal, onSettled: () => refresh('page') })\n // the page that owns the job's state machine: the value in hand\n const { data, status } = usePolling(read, { every: 1_500, until: isTerminal, onSettled: (f) => dispatch(f.status) })\nSettled = stopped until refresh() or the inputs change. status: 'reading' |\n'paused' | 'settled' | 'idle'.\n\nSERVER-SENT EVENTS - when something can push. Better per update (bytes only\non change, instant), but holds a connection per open tab (fine on Bun/Node,\na limit on Workers or a small container), is uncacheable, and needs a source\nof change to yield from - a generator that polls the DB itself just moved the\npolling. An ordinary route.ts: beside its pages, runs middleware.ts above it.\n\\`\\`\\`ts\n// src/app/api/orders/[id]/events/route.ts\nimport { events, named } from '@rsc-kit/core/events'\nexport const GET = events(async function* ({ params, signal }) {\n const { id } = await params\n for await (const status of orderStatus(id, { signal })) yield { status }\n // yield named('paid', order, { id: order.id }) names a message / gives an id\n})\n\\`\\`\\`\n\\`\\`\\`tsx\nimport { useEvents } from '@rsc-kit/core/useEvents'\nconst { latest, all, status, close } = useEvents(\\`/api/orders/\\${id}/events\\`)\n// latest is typed from the route: what its generator yields. { event: 'paid' }\n// types the named messages. A url no route answers fails the typecheck; another\n// site's stream is unknown until named: useEvents<Message>(url).\n\\`\\`\\`\nevents() frames JSON, sends a keepalive, sets text/event-stream + no-store,\nends the generator on disconnect (signal). EventSource reconnects a dropped\nconnection and resumes with Last-Event-ID when you yielded ids; after an error\nanswer (a 502 mid-deploy) the hook opens a new one, backing off 1s to 30s, and\nat once when the browser is back online. close() stops it.\n\nNO LIBRARY NEEDED. Both hooks ARE state: read data (polling) or latest\n(events) and render it. Neither needs TanStack or SWR.\n const { latest } = useEvents(url); const current = latest ?? initial // the server value until the first message\nWITH A STORE - when the value already lives somewhere, hand every value on so\nthat stays the truth: a useState, a reducer, or a cache library:\n useEvents(url, { onMessage: setOrder }) // useState\n useEvents(url, { onMessage: (m) => dispatch({ type: 'update', m }) }) // reducer\n useEvents(url, { onMessage: (m) => queryClient.setQueryData(['order', id], m) }) // TanStack\n useEvents(url, { onMessage: (m) => mutate(['order', id], m, false) }) // SWR\n usePolling(read, { every, onData: setSeats })\nSTABLE CALLBACKS: a callback a timer/subscription/listener calls that must see\nthe latest props is useEffectEvent from React (19.2+), never a ref assigned\neach render, and never in a dependency array. Both hooks are built on it.\n\nERRORS: both hooks expose error as state AND fire onError - a failed poll read\n(the next interval still reads) or a dropped stream (EventSource reconnects\nitself). Use onError for a toast/log; do NOT watch error in a useEffect.\nusePolling's onError gets (error, { failures }) - failed reads in a row, reset\nby a success - so toast on the third, not the first.\nfetchQuery(query, args) types args from the query: fetchQuery(getSeats) for a\nquery that takes nothing, fetchQuery(status, [{ id }]) refused if the query's\ninput has no id.\nDo NOT put a stream on query() or on a server action, and do NOT poll from\ninside an events() generator.\n\nFull guides: read_guide({ slug: 'queries' }) for polling, read_guide({ slug:\n'api-routes' }) for the streaming route.`,\n },\n {\n topic: 'images',\n summary: 'Responsive images with no optimizer - unpic for a CDN, imagetools for files in the repo',\n body: `There is NO image component and NO image server. Do not add next/image or\nwrite an optimizer route. next/image is a srcset-writing component plus a\nresize-on-request process; the first is a library, the second belongs to the\nCDN.\n\nAn image on a CDN (Cloudinary, imgix, Cloudflare Images, Bunny, Vercel,\nNetlify, ...): @unpic/react. Plain component, works in a server component,\nships no javascript, detects the CDN from the url:\n\n\\`\\`\\`tsx\nimport { Image } from '@unpic/react'\n\n<Image src=\"https://res.cloudinary.com/demo/image/upload/sample.jpg\" layout=\"constrained\" width={800} height={600} alt=\"...\" />\n\\`\\`\\`\n\nA file in the repo, a handful of them: vite-imagetools, resized ONCE at build\ntime. Add imagetools() to the vite plugins, then:\n\n\\`\\`\\`tsx\nimport hero from '../hero.png?w=400;800;1200&format=webp&as=srcset'\nimport heroSrc from '../hero.png?w=800&format=webp'\n\n<img srcSet={hero} src={heroSrc} sizes=\"(min-width: 800px) 800px, 100vw\" width={800} height={600} alt=\"...\" />\n\\`\\`\\`\n\nDeclare the query tails in src/images.d.ts, or the build's typecheck stops on\nthe imports (a pattern may hold ONE *, so '*?*' matches nothing):\n\n declare module '*&as=srcset' { const srcset: string; export default srcset }\n declare module '*&format=webp' { const url: string; export default url }\n\nHundreds of files in the repo: that is a CDN's job; move them and use unpic.\nAn icon or a logo: a plain <img>, or inline the svg.\n\nFull guide: read_guide({ slug: 'images' }).`,\n },\n {\n topic: 'scripts',\n summary: 'Third-party scripts - analytics, tag managers - without a Script component',\n body: `Write the script tag. React 19 does what Next's Script component existed for.\n\nAn external script with async, rendered from a server component, is HOISTED\ninto head and DEDUPLICATED by React - the same src in three components is one\ntag. That is afterInteractive:\n\n\\`\\`\\`tsx\n<script async src=\"https://www.clarity.ms/tag/abc123\" />\n\\`\\`\\`\n\nAn inline snippet renders where it is written and runs during parse, before\nhydration - the earlier moment, which is what an analytics snippet wants:\n\n\\`\\`\\`tsx\n<script id=\"ms-clarity\" dangerouslySetInnerHTML={{ __html: '...' }} />\n\\`\\`\\`\n\nPut site-wide scripts in the ROOT LAYOUT, which renders once and is kept\nacross navigations.\n\nThere is no Script component to import. The only case needing one - a script\nthat touches DOM React rendered, or an onLoad callback - is a client component\nwith useEffect that creates the tag. Ten lines of the user's own.`,\n },\n {\n topic: 'testing',\n summary: 'Unit-testing actions, queries and routes; the whole app without a port',\n body: `Almost everything is a function. Any test runner works.\n\n**Actions, queries, api routes: import and call.** \"use server\" is a string in\na test file, so the function is importable. An action built on the action\nclient runs its whole middleware chain when called and RETURNS its failures:\n\n\\`\\`\\`ts\nconst result = await createPost({ title: '' })\nexpect(result.validationErrors).toEqual({ title: ['too short'] })\n\\`\\`\\`\n\nAn api route takes a Request and the context the engine gives it - params is a\nPROMISE:\n\n\\`\\`\\`ts\nconst res = await GET(new Request('https://app.test/api/x'), { params: Promise.resolve({ id: '1' }) })\n\\`\\`\\`\n\n**Anything reading cookies() or headers():** open the request scope yourself.\n\n\\`\\`\\`ts\nimport { withRequest } from '@rsc-kit/core/request'\nawait withRequest(new Request('https://app.test/', { headers: { Cookie: 'session=abc' } }), currentUser)\n\\`\\`\\`\n\nA library written for Next that reads next/headers (flags/next) needs the\nbuild's alias in tests/preload.ts: mock.module('next/headers', () => import('@rsc-kit/core/request')).\n\n**The whole app as Request -> Response, no port:**\n\n\\`\\`\\`ts\nimport { createTestApp } from '@rsc-kit/core/testing'\nconst app = await createTestApp()\nconst res = await app.fetch('/admin', { redirect: 'manual' }) // real router, real middleware\n\\`\\`\\`\n\nIt builds when anything the build is made from is newer than the last build -\nsource, vite.config, package.json, the lockfile, the installed @rsc-kit/core -\nso an upgrade is picked up. This is where a guard that never ran or a 404 that\ncame back 200 shows up.\n\nTo assert something is NOT on a page, use app.markup(path), not fetch(): the\ndocument streams the page's payload in <script>s, which holds every prop a\nclient component was given, shown or not. markup() is the document with every\n<script> removed. Do NOT strip scripts by hand.\n\n**A Go or Laravel backend, answered in the test (nothing running):**\n\n\\`\\`\\`ts\nimport { createTestApp, hostReply, HOST_MIDDLEWARE } from '@rsc-kit/core/testing'\nconst app = await createTestApp({\n host: {\n 'Apps.list': ({ args, headers }) => [{ id: 1 }], // rpc('Apps.list', ...)\n 'Apps.create': () => hostReply.invalid({ name: ['Taken.'] }),\n [HOST_MIDDLEWARE]: ({ args, headers }) => // middleware.ts guards; args[0] = names\n headers.get('cookie')?.includes('session=') ? true : hostReply.unauthenticated(),\n },\n})\n\\`\\`\\`\n\nThe host handlers are TYPED from rsc-host.json (interface RscHostFunctions in\n.rsc-kit/rsc-env.d.ts): only functions the backend has, typed args, and the\nfunction's result type or a hostReply answer - never a raw { status: 401 } or a\nmade-up shape. Refusals ALWAYS through hostReply, which passes the same\nconformance suite the real adapters do. hostReply.fail(message) = an unexpected\nerror. Answers go through the real host-call client, so they behave as the backend's\nwould: hostReply.unauthenticated() -> 401, unauthorized() -> 403,\nredirect(to), refuse(status, message), invalid(errors) -> validationErrors,\nrevalidating(result, ...regions). A name with no handler fails the call,\nnaming it. RSC_BACKEND in .env is not called.\n\nA url the app forwards to the backend (a Go route, /login) is a 502 in a test\nunless createTestApp({ backend: (request) => new Response(...) }) answers it;\nit gets the request as the backend would.\n\n**What still needs a browser:** a server action called OVER THE WIRE (the id is\nReact's and private), hydration, navigation. Playwright against vite preview.\nThat limit is narrower than Next's: the action's logic is a unit test here.\n\n**What to write when you add something.** Before running check, not after:\n\n- A guarded route (middleware.ts, or a page reading the session): a stranger\n is turned away, and someone signed in gets 200.\n \\`\\`\\`ts\n expect((await app.fetch('/admin', { redirect: 'manual' })).status).toBe(302)\n expect((await app.fetch('/admin', { headers: { Cookie: 'session=ada' } })).status).toBe(200)\n \\`\\`\\`\n- An action: its refusal, by calling it. Bad input answers validationErrors;\n a stranger answers serverError (or throws ServerAuthenticationError if you\n built it without the client).\n \\`\\`\\`ts\n expect((await createPost({ title: '' })).validationErrors).toBeDefined()\n \\`\\`\\`\n- An action that takes an id: someone else's id is refused. This is the IDOR\n test and the one most often missing.\n- A query: the shape of its answer, and what a filter changes.\n- An api route: status, content-type, and the 4xx it answers to a bad body.\n- A page that should stay static: assert on the build report - no test, a CI\n check that build-report.json still says frozen for it.\n\nDo NOT start a dev server, spawn a process or pick a port in a test. Do NOT\nadd a second runner. The one in tests/ goes through the real build already.`,\n },\n]\n\n/** Every topic, with one line each — what a caller reads before choosing. */\nexport function listTopics(): string {\n return [\n 'Topics. Ask for one with how_to({ topic }).',\n '',\n ...RECIPES.map((r) => `${r.topic.padEnd(16)} ${r.summary}`),\n ].join('\\n')\n}\n\n/** One recipe, or the list plus a nudge when the topic is not one. */\nexport function howTo(topic: string): string {\n const wanted = topic.trim().toLowerCase().replace(/[\\s_]+/g, '-')\n const found =\n RECIPES.find((r) => r.topic === wanted) ??\n // A near miss is common and worth answering rather than refusing: someone\n // asks for \"form\" or \"queries\" and means the obvious thing.\n RECIPES.find((r) => r.topic.startsWith(wanted) || wanted.startsWith(r.topic)) ??\n // The summary is prose, so a multi-word ask is matched with its spaces back.\n RECIPES.find((r) => r.summary.toLowerCase().replace(/[-\\s]+/g, ' ').includes(wanted.replace(/-/g, ' ')))\n\n if (!found) return `No topic \"${topic}\".\\n\\n${listTopics()}`\n\n return `# ${found.topic} — ${found.summary}\\n\\n${found.body}`\n}\n\n/** For tests, so a recipe cannot be added without being reachable. */\nexport const TOPICS = RECIPES.map((r) => r.topic)\n"]}
|
|
1
|
+
{"version":3,"file":"recipes.js","sourceRoot":"","sources":["../src/recipes.ts"],"names":[],"mappings":"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","sourcesContent":["// How to build the things this framework has, in the shape that works.\n//\n// The other half of this server, and the more useful one. Introspection answers\n// \"what did my build do\"; this answers \"how do I do X here\", which is the\n// question an agent actually has — and the one it otherwise answers from Next\n// and React habits that produce code which looks right and is not.\n//\n// Long-form on purpose. AGENTS.md has to be short enough to sit in context for\n// every turn, so it can only say the rule. These are fetched when the topic\n// comes up, so they can afford the working example and the caveat under it.\n//\n// Every snippet here is the recommended spelling from the guides, not a\n// paraphrase. When a guide changes, this changes with it — a recipe that has\n// drifted is worse than no recipe, because it is followed with confidence.\n\nexport interface Recipe {\n topic: string\n summary: string\n body: string\n}\n\nconst RECIPES: Recipe[] = [\n {\n topic: 'forms',\n summary: 'Submitting to a server action, with pending state and field errors. Uncontrolled by default; read with error() and field()',\n body: `THE RULE: forms are UNCONTROLLED. Inputs keep their value in the DOM,\nan initial value is defaultValue, the action reads FormData. Do NOT write\nuseState + value/onChange per input, and do NOT reach for TanStack Form.\n\nONE WAY TO READ A FORM - two words, the same everywhere:\n error('title') the field's error, or undefined. !!error('title') is invalid.\n Nested fields are paths: error('address.city'), error('items[0].name').\n The names are TYPED from the action's input (a stub's typed\n parameter, or createActionClient().input(schema)), so\n error('titel') fails tsc: Field names. Open when the action\n says nothing (a FormData function, a url, a cast). A component\n below the form types its prop with FieldNamesOf<typeof action>\n from '@rsc-kit/core/form'. A WRAPPER you write around a stub\n (a function of a FormData) declares its fields on its\n parameter: (form: FormFields<'id' | 'name'>) - then error()\n is closed to them. A TEAM-SCOPED stub func(ctx, team, in) is a\n form's action as stub.bind(null, team): the form fills the\n struct after the bound args, and the names stay - but bind\n into a const first, inline bind() loses them. A stub whose\n parameters are plain strings has no struct for a form to fill:\n wrap it. A form that posts NOTHING (values in React state)\n names its fields on the form: <Form fields={['size']}>.\n formError the refusal NOT about a field (serverError - a 402/409 - or a\n form-level validation message), or undefined. Render it as\n {formError && <p role=\"alert\">{formError}</p>}; do not keep it\n from onError in your own state.\n field('title') a binding to spread - ONLY for a field whose value the UI must\n hold (a character count, a live preview, a control with no\n native element). Everything else stays uncontrolled.\nBeside them: pending, dirty, succeeded, recentlySucceeded, reset(), clearErrors(),\nand data (what is being submitted).\n\nThere is nothing else. No errors map, no fieldState, no useFormValues, no\nuseFormStatus - those were removed; if you see them in old code, convert them.\n\nUse <Form>. It takes the server action itself, not a url.\n\n\\`\\`\\`tsx\n'use client'\nimport Form from '@rsc-kit/core/form'\nimport { createPost } from '../actions'\n\nexport function NewPost() {\n return (\n <Form action={createPost} schema={schema}>\n {({ pending, error }) => (\n <>\n <input name=\"title\" />\n {error('title') && <p>{error('title')}</p>}\n <button disabled={pending}>Save</button>\n </>\n )}\n </Form>\n )\n}\n\\`\\`\\`\n\nIn a child component, useForm() returns EXACTLY what the render prop gets, so\nthe code is the same:\n\n\\`\\`\\`tsx\nimport { useForm } from '@rsc-kit/core/form'\n\nfunction SubmitButton() {\n const { pending } = useForm()\n return <button disabled={pending}>Save</button>\n}\n\\`\\`\\`\n\n(Not React's useFormStatus - a different hook with a different answer.)\n\nPassing \\`schema\\` validates in the browser BEFORE the action is called, so a\nmistake costs no round trip. It is a courtesy, never a control: the action is a\npublic endpoint reachable without your form, so the server must check too.\n\nA schema on the server (\\`client.input(schema)\\`) does NOT give you client-side\nvalidation. Pass it to the form as well — the same schema is fine.\n\nA field is checked when it is LEFT, not as it is typed (the form listens for\nfocusout, so uncontrolled fields are covered), and on submit. So error() has\nnothing for a field nobody reached - there is no \"touched\" to check first.\n\nValues are uncontrolled, so an initial one is React's own \\`defaultValue\\`. A\nrefused submit keeps what was typed, because the DOM kept it.\n\nA repeated name is an array. With one selected it is a string, which no\nz.array() accepts - so for anything that is a list by nature end the name in\n\\`[]\\` and it is always an array, brackets dropped from the key:\n\n\\`\\`\\`tsx\n<input type=\"checkbox\" name=\"tags[]\" value=\"react\" /> // -> { tags: ['react'] }\n\\`\\`\\`\n\nNames that describe a shape build it: \\`address.city\\` nests, and\n\\`items[0].name\\` (or \\`items[0][name]\\`) makes an array of objects. That is\nthe shape the schema was written against, and error() takes the same path.\n\nBinding a field - a rich editor, a Radix select, a value read as it is typed:\n\n\\`\\`\\`tsx\n<Form action={save} defaultValues={{ body: '' }}>\n {({ field, error }) => (\n <>\n <Editor {...field('body')} />\n <span>{field('body').value.length}/100</span>\n {error('body') && <p>{error('body')}</p>}\n </>\n )}\n</Form>\n\\`\\`\\`\n\nfield() gives { name, value, onChange, onBlur } - react-hook-form Controller's\nfour. onChange takes a DOM event OR a bare value. A bound field is still an\nordinary named input, so it arrives in FormData with the rest.\n\nshadcn Field components, with the two words:\n\n\\`\\`\\`tsx\n<Field data-invalid={!!error('title')}>\n <FieldLabel htmlFor=\"title\">Title</FieldLabel>\n <Input id=\"title\" name=\"title\" aria-invalid={!!error('title')} />\n <FieldError>{error('title')}</FieldError>\n</Field>\n\\`\\`\\`\n\nFieldError renders its children and renders nothing when there are none.\n\nA bound field re-renders the form. For a LARGE controlled form, put the field in\nits own component and use useField - the same two words for one name, and it\nre-renders that component and nothing else:\n\n\\`\\`\\`tsx\nfunction Title() {\n const { field, error } = useField('title')\n return <Input {...field} aria-invalid={!!error} />\n}\n\\`\\`\\`\n\nA preview or summary reads a bound value the same way:\n\\`const { field } = useField('title'); field.value\\`. Only BOUND values can be\nread - an uncontrolled input's value is the DOM's.\n\nFor something that is NOT a descendant - a top bar - create the store above both\nand hand it in; useField(name, store) reads it:\n\n\\`\\`\\`tsx\nconst store = useFormStore({ title: '' })\n\n<TopBar store={store} /> // outside the form: useField('title', store)\n<Form action={save} store={store}>…</Form>\n\\`\\`\\`\n\nCreating the store does not subscribe to it, so the holder does not re-render\nper keystroke.\n\nA submit from outside the form is html, not a second api:\n\n\\`\\`\\`tsx\n<Form id=\"bug-report\" action={reportBug}>…</Form>\n<Button type=\"submit\" form=\"bug-report\">Submit</Button>\n\\`\\`\\`\n\nIt works before hydration. The action is on the form element as well as in the\nsubmit handler, so the markup is submittable on its own; a refusal shows on its\nfields through error() even with no javascript.\n\n**components.json needs \"rsc\": true.** shadcn's init sees a plain Vite\nproject and writes false, and then a dialog or dropdown it adds has no\n\"use client\". create-rsc-kit --shadcn and rsc-kit init --shadcn set it; after\na bare \\`shadcn init\\`, set it by hand. \\`import { cn } from \"cn\"\\` in its\ncomponents is correct: cn is shadcn's own package (lib/utils re-exports it).\n\n**shadcn/ui works as-is.** Input, Textarea, Button and Label are styled native\nelements, so \\`name\\` does what it always does. Select, Checkbox, Switch and\nRadioGroup are Radix underneath and render a hidden native control whenever\ngiven a \\`name\\` - omit it and they are invisible to the form.\n\nDo NOT use shadcn's own Form/FormField/FormControl with this. Those wrap\nreact-hook-form, a different system for the same job. One or the other.\n\nA SEARCH ON THE PAGE IT IS ON: a GET form (action is a url string) whose action\nis the page the visitor is already on keeps that page on screen while the query\nruns - no shell is shown first, the old results stay, an input keeps what was\ntyped, and Back returns to the query it left. pending is true until the new\nresults are on screen:\n\n\\`\\`\\`tsx\n<Form action=\"/lookup\" method=\"get\">\n {({ pending }) => <><input name=\"q\" /><button>{pending ? 'Searching…' : 'Search'}</button></>}\n</Form>\n\\`\\`\\`\n\nTo show a skeleton where the results are instead of the old results, style them\nfrom the form's own data-pending attribute (the results stay OUTSIDE the form, no\nclient wrapper): section:has(form[data-pending]) #results { display: none }, and\nthe same selector showing a #results-skeleton.\n\nDo NOT patch around a skeleton on an in-page search with your own useState,\nrouter.replace or a Suspense key: the form already does this. It is the form\nonly - a <Link> or visit() to the same path with another query is a page of its\nown. A search to ANOTHER page still shows that page's shell first.`,\n },\n {\n topic: 'prefetch',\n summary: 'Making a navigation feel instant',\n body: `\\`<Link>\\` fetches its payload as it comes into view, on EVERY device\n(desktop too), when the browser is idle, as Next does - and again when the\nlink comes back into view or the tab is visible again, a request only once\nthe held payload has expired (no timer: an idle page fetches nothing) - the\nfirst 12 links in view per page (a payload is the whole page, 30 KB for a\nproduct with its related products; the rest wait for a touch or a settled\nhover) - the bytes only, held 30 s (per-visitor page) or 5 min (a page the build made,\nmarked public), and the eager <img>s the payload names are preloaded at low\npriority as it lands (the first 6 a page; not loading=\"lazy\"; nothing under\nSave-Data), so a product picture is decoded before the tap. Do NOT write an\nimage-prefetch route or an effect per card in the app. It is decoded - which\nloads the page's client chunks - on intent: a pointer settled 100 ms on the link, a mousedown, a touchstart,\neach 60-300 ms before the click. A click then finds a decoded tree and\nnothing on the network is between the click and the page: the SPA feel,\non a slow network too. A link to a guarded page (Sign in -> /agent ->\n/login) prefetches the redirect's destination too, so the tap asks for\nnothing. Save-Data turns the viewport prefetch off. To MEASURE a navigation\n(the number \"slow on my phone\" needs): every navigation leaves User Timing\nmarks rsc-kit:navigate:start / :decoded / :applied and a measure\nrsc-kit:navigate (click to the commit) - in the Performance panel, or\nperformance.getEntriesByName('rsc-kit:navigate') in a console; a gap\nbefore :decoded is network or chunks (the link was not on screen, or\nprefetch={false}), after :applied is the render. A tap BEFORE the runtime has\nhydrated is held by the bootstrap script and navigated to once the router\nis wired (a document load after 4 s if the runtime never comes) - so a slow\nphone's first tap is not a full reload. A form posting a server action\nsubmitted in that window is held and submitted again after hydration, so\n\"Add to cart\" tapped early is an action, not a full-page POST. Usually there is nothing to do; do\nNOT add a viewport observer, touch handler or pre-hydration click shim in\nthe app. A tag manager on the main thread (Facebook Pixel, Clarity) is the\nusual reason hydration is late: load those after the page is interactive.\nA <ViewTransition> around the page is the usual reason a navigation is\nslow on an iPhone (every iOS browser is WebKit): WebKit snapshots the\noutgoing page at its FULL height, a 12,000 px landing page is 44 megapixels\nat 3x, ~600-900 ms frozen per navigation and enough memory for iOS to\ndiscard the tab (which returns as a reload). A navigation here is an\ninstant swap, like Next - do NOT add a page fade in a port; a port that\nhas one should remove it. The transition worth having is a shared element\n(<ViewTransition name=...> on one image). If a page fade is wanted anyway,\n<PageTransition className=...> from @rsc-kit/core/PageTransition is the\none that skips WebKit (and reduced motion), and ships its own CSS - a\n120 ms cross-fade with pointer-events off on the overlay; nothing to add to\nthe stylesheet. Props: duration (ms, 0 = instant swap), webkit=\"run\" for a\none-screen page that measured fine. See read_guide view-transitions.\n\n\\`\\`\\`tsx\nimport Link from '@rsc-kit/core/Link'\n\n<Link href=\"/orders\">Orders</Link>\n<Link href=\"/orders\" prefetch={false}>Orders</Link> // opt out\n<Link href=\"/orders\" cacheFor={30_000}>Orders</Link> // hold the payload longer\n\\`\\`\\`\n\n\\`href\\` is typed to the routes the build found, so a link to a page that no\nlonger exists stops compiling - and a url the backend owns is one once it is\nnamed in backendPaths. Cast with \\`as Route\\` only when the destination is\ngenuinely computed.\n\nTo prefetch from code — a row about to be clicked, a wizard's next step:\n\n\\`\\`\\`ts\nimport { prefetch } from '@rsc-kit/core/router'\n\nprefetch('/orders/42')\nprefetch('/orders/42', undefined, { intent: true }) // and decode it now: the chunks too\n\\`\\`\\`\n\nWhat is prefetched is the RSC payload, not the html, so it is small and it warms\nthe same cache the navigation will read. Never the page on screen, and never a\npage still held behind it (the page just left): a navigation to that reveals\nit. After an action that revalidated, and after refresh(), every prefetched\npayload and every held page is dropped - they are from before the write - so\nvisit() to a list after creating a row fetches the list with the row in it.\nWhat an action re-rendered goes under the page it was invoked on: a tap that\nleft the page while the action was in flight is answered by fetching the page\nnow showing again, whole, never by putting the old page under the new url.\n\nThe client components a prefetched page renders are loaded (code only) as its\npayload lands - the first eight per page, bounded per document - so the first\nvisit to a page does not wait on a chunk after the page arrived. Nothing to\nconfigure. Serve over HTTP/2 (Cloudflare does by default): every client\ncomponent is its own small chunk.`,\n },\n {\n topic: 'validation',\n summary: 'Checking input — forms, actions, urls and request bodies',\n body: `One contract everywhere: any Standard Schema (Zod, Valibot, ArkType).\n\n**Actions** validate on arrival and RETURN their failures, because React strips\na thrown message in production:\n\n\\`\\`\\`ts\nexport const createPost = client.input(schema).handler(async ({ input, ctx }) => …)\n\\`\\`\\`\n\n**Urls** validate by exporting a schema beside the page or route:\n\n\\`\\`\\`ts\nexport const params = z.object({ slug: z.string().min(1) })\nexport const searchParams = z.object({ page: z.coerce.number().int().min(1).default(1) })\n\\`\\`\\`\n\nValues arrive parsed and typed — \\`?page=3\\` is the number 3, a missing one is\nthe default. Never hand-parse \\`Number(searchParams.get('page'))\\`.\n\nThe same schema types every LINK to that page. Write search params as an\nobject, never as a string:\n\n\\`\\`\\`tsx\n<Link href=\"/search\" search={{ q: 'shoes', page: 2 }}>…</Link> // typed by the page's schema\nvisit(href('/search', { q: 'shoes' })) // same check, as a string\n\\`\\`\\`\n\nA key the page never reads, or a number written as text, does not compile;\na key the page requires is required on the link. A page with no schema takes\nany scalars. Do NOT build \\`?q=\\${q}\\` by hand when the page has a schema.\n\n**Api route bodies** the same way:\n\n\\`\\`\\`ts\nexport const body = z.object({ title: z.string().min(1) })\n\nexport async function POST(request: Request, { body }) {\n const { title } = await body\n}\n\\`\\`\\`\n\nThe failures answer differently on purpose:\n\n bad params 404 — the url does not describe a page\n bad searchParams the error boundary (400 for an api route)\n bad body 422, the status an action already uses\n\nA bad query is deliberately NOT a 404, or one bad link makes a real page look\ndeleted.`,\n },\n {\n topic: 'action-client',\n summary: 'Middleware for server actions, so a check cannot be forgotten',\n body: `\\`\\`\\`ts title=\"src/server/client.ts\"\n'use server'\nimport { createActionClient } from '@rsc-kit/core/action'\n\nexport const client = createActionClient({ onError: report })\n .use(async ({ next }) => {\n const user = await currentUser()\n\n if (!user) throw new ServerAuthenticationError()\n\n return next({ ctx: { user } })\n })\n\\`\\`\\`\n\n\\`\\`\\`ts title=\"src/server/posts.ts\"\n'use server'\nimport { client } from './client'\n\nexport const createPost = client.input(schema).handler(async ({ input, ctx }) => …)\nexport const getPosts = client.query(async ({ ctx }) => …)\n\\`\\`\\`\n\n\\`.handler()\\` is a mutation (POST). \\`.query()\\` is a read (GET). Both run the\nchain, so \\`ctx.user\\` is typed and non-null inside them.\n\nFor a failure the schema cannot know - an account not found, a slug taken -\nthe handler is given \\`fieldErrors\\`, typed to its own input so a field the\nschema does not have is a compile error:\n\n\\`\\`\\`ts\n.handler(async ({ input, fieldErrors }) => {\n if (!account) return fieldErrors({ email: 'Account not found' })\n})\n\\`\\`\\`\n\nWRITE return fieldErrors(...). It throws either way, but TypeScript cannot see\na never-return through a destructured argument, so without the return the\nvalue you checked stays possibly-undefined on the next line. It lands in\nvalidationErrors on that field, the same place a schema refusal does. This is\nnext-safe-action's returnValidationErrors with no schema argument and no\n_errors nesting.\n\nA plain \"use server\" function with no action client imports the same thing,\nuntyped, from '@rsc-kit/core/action'. Same rule: return fieldErrors(...). <Form>\nshows the fields as error('name') and the \"\" key as formError, and useAction\nreturns them as validationErrors; AWAITED DIRECTLY it rejects with\nServerValidationError (@rsc-kit/core/errors: .errors, .fieldErrors, .formErrors)\n- as does a backend stub whose input was refused - so code after the await does\nnot take a refused input for its result. One rule for a stub: anything that did\nnot happen REJECTS (ActionRefusedError, ServerValidationError); only a redirect\nresolves ({ redirected }), and isRedirected is the one check on a resolved value.\n\nWhen the INPUT is fine and the answer is still no - a project with orders\nattached, a plan without the feature - that is a refusal, not a field error.\nUse refuse(message, data), and declare the data's shape with .refusal(schema):\n\n\\`\\`\\`ts\n.refusal(z.object({ blockers: z.array(z.object({ id: z.number(), href: z.string() })) }))\n.handler(async ({ input, refuse }) => {\n if (attached.length) return refuse('Still in use', { blockers: attached.map(toLink) })\n})\n\\`\\`\\`\n\nThe message is result.serverError (a form's formError) and is never replaced\nby onError's generic message. The data is result.refusal, typed from the\nschema and checked against it (a form's formRefusal, typed from the form's\naction - no cast; a component below the form uses useForm<Values, Refusal>(). Do NOT encode blockers into the message or into\nfieldErrors({ '': ... }) - the links are lost. Do NOT re-derive them from the\npage's own list - a refusal decided at the moment of the write knows what is\nblocking it now. A backend refuses the same way (Laravel Rsc::refuse(), Go\nRefuseWith) and arrives as the same result.refusal. Data from an action with\nno .refusal(schema) is not sent. Through a generated stub (<Form\naction={ordersDelete}>) only the MESSAGE arrives, as formError: a plain\nfunction has no .refusal(schema), so the data is left out and the log says so -\nto act on it, call rpc() from a createActionClient action with .refusal(schema).\nAwaited DIRECTLY, a stub REJECTS with ActionRefusedError (@rsc-kit/core/errors:\n.message, .status), so code after the await does not run for a refused write -\nonly a redirect resolves. <Form> shows it as formError, useAction as serverError.\nA query built with .input(schema) takes that schema's input (a wrong key fails\ntsc); a query cannot redirect - throw ServerAuthenticationError (401) instead. Any backend refusal with a status, data or\nnot (Go Refuse(503, \"busy\"), Laravel abort(429)), and a backend's 401, 403 or\n404, arrive as result.serverError with the backend's message - never onError's.\nThrough a generated stub they are formError, and awaited directly an\nActionRefusedError with .status 401, 403 or 404 - a backend 404 in an action\nis its message, never a 500 (an action has no not-found page).\nDo NOT write an onError that unwraps those; only a real failure reaches it. A\n.query() read rejects with the same message, and fetchQuery's error carries\n.status and .refusal.\n\nThe point is not convenience. An action cannot be added without the check,\nbecause there is no other constructor to reach for.\n\nStack clients for a narrower rule:\n\n\\`\\`\\`ts\nexport const admin = client.use(async ({ ctx, next }) => {\n if (!ctx.user.isAdmin) throw new ServerAuthorizationError()\n return next({ ctx })\n})\n\\`\\`\\`\n\nRoute \\`middleware.ts\\` does NOT run for actions — an action renders no route.\nThat is why the check goes here.\n\nThe built action is typed by its schema: createPost({ title: 1 }) fails the\ntypecheck; FormData is accepted beside it (how <Form> calls it). It always\nresolves to an object - data | validationErrors | serverError | redirected.\n\nFrom a button, not a form - useAction (next-safe-action's useAction, and its\nuseOptimisticAction through React's useOptimistic):\n\n 'use client'\n import { useAction } from '@rsc-kit/core/useAction'\n const [shown, hide] = useOptimistic(posts, (list, id: number) => list.filter((p) => p.id !== id))\n const { execute, executeAsync, isPending, status, result, reset } = useAction(archivePost, {\n optimistic: ({ id }) => hide(id), // inside the transition; React reverts it on failure\n onSuccess: (data) => …, // typed from the handler\n onError: (message, result) => toast.error(message), // serverError, or the first validation message\n onSettled: (result) => …,\n })\n <button disabled={isPending} onClick={() => execute({ id })}>\n\nexecute never throws (a thrown failure becomes serverError); a redirect runs no\ncallback and sets no state; execute keeps one identity and reads the newest\ncallbacks.\n\nAn action body - arguments and any uploaded files, read whole - is capped at\n8 MB; over it the answer is 413 before a byte is kept. rscKit({ maxActionBody })\nraises it. For large files, mint a pre-signed url and upload straight to storage.`,\n },\n {\n topic: 'data',\n summary: 'Loading data, streaming it, and when the browser needs to refetch',\n body: `**In a server component, just await it.** No loader, no getServerSideProps.\n\n\\`\\`\\`tsx\nexport default async function Page() {\n const posts = await db.posts.all()\n}\n\\`\\`\\`\n\n**Better: do not await.** Pass the promise down and let a client component\nresolve it — the shell paints at once and the rows stream into the same\nresponse, with no request from the browser:\n\n\\`\\`\\`tsx\nexport default function Page() {\n const posts = getPosts() // not awaited\n\n return (\n <Suspense fallback={<Skeleton />}>\n <List posts={posts} /> {/* 'use client': use(posts) */}\n </Suspense>\n )\n}\n\\`\\`\\`\n\nReach for this first. It is the thing RSC is for.\n\n**When the BROWSER decides to refetch** — a filter, another page, a refresh —\ncall fetchQuery. It is a plain async function that returns the typed answer;\nNO library is needed:\n\n\\`\\`\\`tsx\nconst [listings, setListings] = useState(initial) // the server-rendered value\nconst [pending, start] = useTransition()\nconst show = (kind) => start(async () => setListings(await fetchQuery(getListings, [kind])))\n\\`\\`\\`\nWorks in an onClick, onSubmit, useEffect - anywhere in the browser.\n\n**When you want CACHING** (stale-while-revalidate, dedupe, offline), hand the\nsame call to the library that holds the answer; this package ships none:\n\\`\\`\\`tsx\nuseQuery({ queryKey: ['posts', kind], queryFn: () => fetchQuery(getPosts, [kind]) })\nuseSWR(['posts', kind], () => fetchQuery(getPosts, [kind]))\n\\`\\`\\`\nfetchQuery sends the read as a GET and goes to the server every time, which\nis what a fetcher needs. It will never cache, dedupe or batch: a client\ncache is the library's job, and a batch would lose the per-read cache key a\nGET has. The ladder, most reads stopping on the first rung:\n 1. the value now: fetchQuery + setState\n 2. survive a reload / let a CDN serve it: query(fn, { cache: 'public', maxAge })\n - HTTP caching, no code in the page\n 3. staleness, background refresh, optimistic updates, shared across\n components: TanStack or SWR with fetchQuery as the fetcher\nDo not add a cache on top of it, and do not install TanStack for a single\nbutton that reads once.\n\nKeep the arrow: TanStack calls a bare \\`queryFn\\` with its own context, and a\nserver function serialises whatever it is handed.\n\nA value that keeps CHANGING while someone watches - polling, SSE, realtime -\nis how_to live-data, not this.`,\n },\n {\n topic: 'suspense',\n summary: 'Where boundaries go, and why the build cares',\n body: `A boundary is what lets a page be stored with a hole in it rather than not\nstored at all.\n\n\\`\\`\\`tsx\n<Suspense fallback={<Skeleton />}>\n <Slow />\n</Suspense>\n\\`\\`\\`\n\nTHE DEFAULT PATTERN - write pages this way: the page is a SYNCHRONOUS\ncomponent; fixed copy, titles and frames go straight in it (they are the\nstored shell); each read is its own async \"slot\" component under its own\n<Suspense>, with a skeleton the shape of what it replaces:\n\n\\`\\`\\`tsx\nexport default function ShopifyPage() { // not async\n return (\n <Card>\n <CardTitle>Shopify</CardTitle> {/* in the shell */}\n <Suspense><ConnectButtonSlot /></Suspense> {/* nothing until known */}\n <Suspense fallback={<ConnectionsSkeleton rows={1} />}>\n <ConnectionSlot /> {/* async: its own read + check */}\n </Suspense>\n </Card>\n )\n}\n\\`\\`\\`\n\nEach slot does its own read and its own permission check, so a fast part is\nnot held back by a slow one, and a slot can move or be revalidated as a\nsection on its own.\n\nloading.tsx wraps the WHOLE page in one boundary. Use it only for a page that\nis one read and nothing else (a detail page whose skeleton is the page). Do\nNOT reach for it by default:\n- the heading/copy/frames the page already knows wait with the data;\n- the slowest read holds back every other part;\n- notFound()/redirect() or a backend 404 decided under it may already have a\n 200 on the wire for a person (the page then ends with a noindex tag; a\n crawler is answered with the finished page and gets the real 404) - a\n check that must be a 404 for everyone goes in middleware.ts, which runs\n before anything is sent;\n- it sits BELOW its layout: a layout that awaits (params, session) needs its\n own <Suspense> inside the layout;\n- a ROOT loading.tsx hides pages waiting in the wrong place (the build only\n warns \"nothing painted without the root loading.tsx\").\n\nThe build renders every page. Whatever has not resolved when the budget expires\nbecomes the hole; everything above it is stored and served instantly. So a page\nwith no boundary above its slow part cannot be stored at all — the build says\nso:\n\n ƒ /orders\n blocks before anything can paint. Add a loading.tsx beside it, or put a\n <Suspense> above the waiting, and it has a skeleton to store.\n\nFix that by moving the read into a slot under <Suspense>, not by adding a\nloading.tsx.\n\nA painted fallback stays up at least 300 ms - React's rule: content that\narrives within 300 ms of the fallback painting waits until then. A stored\nshell's holes arrive a moment after the shell, so fast data shows its\nskeleton for the full 300 ms (a flash on every app launch). So: no boundary\naround data that is always fast; fallbacks shaped like the content (same\ncard, same size, numbers blank); no animate-pulse on short-lived fallbacks.\n\nA boundary does NOT fix a frozen \\`Date.now()\\`. Prerendering renders straight\nthrough a component that never awaits, so the value is captured exactly as\nbefore. A boundary becomes a hole only when something inside it waits.\n\nredirect() and notFound() inside a boundary still work: the shell has gone\nout, so the status stays 200 for a person - a late notFound() ends the page\nwith <meta name=\"robots\" content=\"noindex\"> - while a search engine or\nlink-preview crawler (matched by user agent) is answered once the whole page\nhas rendered and gets the real 404 or redirect. For a person the redirect\ntravels in the row's error digest and the browser\nperforms it as a navigation, layouts kept. A destination that is not this app's\nis a DOCUMENT LOAD, wherever the redirect is called: redirect('https://other.site/..')\ntype-checks as written, and so does a url the backend owns once it is named in\nbackendPaths (see the backend recipe) - no \\`as Route\\`. The url that redirected\nis replaced in history either way, so Back does not return to a page that sends\nyou out again. A late notFound() is the same: the browser asks for not-found.tsx and shows it where the page was, url unchanged.\nA not-found.tsx beside a layout answers notFound() from every page under it,\ninside that layout - the nearest one above the page wins, like error.tsx, so a\nsection keeps its header on a 404 (app/(shop)/not-found.tsx under\napp/(shop)/layout.tsx). A url no route owns is answered by the ROOT one only.\nerror.tsx's reset() ASKS THE SERVER AGAIN for the page (layouts kept) and shows\nwhat it answers, so a \"Try again\" button recovers from a failure that has\npassed; it is the whole retry - no refresh() beside it, no location.reload().\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\nFOR AN EDGE THAT CACHES PAGES: rsc-kit implements the edge contract (v2,\ndocs: reference/edge-contract). Every server build writes .output/edge.json -\n{ v: 2, build, shells, static, bypass } - whose shells are url patterns (\"/\",\n\"/[team]/[app]\", \"/docs/[...slug]\"), the ◐ pages, and whose static are\nconcrete paths, the ○ pages at the path each was rendered at: both less every\npage with middleware (middleware.ts or the host's export const middleware) or\nbound to the host, at most 200 each, [] when there are none. A root\nmiddleware.ts - where security headers usually go - is above every page and\nempties both lists; the build names the middleware that kept pages out.\nHeaders fixed for every page go in rscKit({ headers: { 'X-Frame-Options':\n'DENY' } }) instead: set on every response, sent to the edge as the page's\nheaders, and the page stays listed. Only the contract's allow list is\naccepted (no Cache-Control or Link); anything else is a build error. GET\n/_edge/page?path= answers a shell, or a static page whole with complete: true;\nPOST /_edge/resume?url= answers a shell's holes. Both answer only a request\nwhose Edge-Token header holds the EDGE_TOKEN env var; unset, both are a 404. A\nstatic page's payload is copied to /assets/_rsc/<hash>.rsc (immutable) and\nnamed in its document, so it boots with no request to the origin. build covers\nthe client bundle and every stored page's bytes, so a server-only change is a\nnew build and an edge drops what it cached; it is not X-RSC-Version, and open\ntabs do not reload for it. There is no v1 (ppr.json, /_ppr/*, PPR_TOKEN) any\nmore. Do NOT derive the lists from .output/server/rsc-static/: a guarded page\nhas a file there too, and _team_ cannot say whether it was a parameter.`,\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. The build names the case that BREAKS: \"N\n server files reach client-only React: <file> (<package>: useState)\" - a\n server file whose code, or a library's under it, reaches a hook the server\n lacks; a render calling it fails \"(0, ...useState) is not a function\". Add\n \"use client\" to that file. Icons and packages that ship their own\n directive are not named.\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 Go type with its own MarshalJSON is unknown in TypeScript - do NOT write a\nnarrowing helper in the page; give the type a JSONSchema method (rsc-kit/go v0.11.0+):\nfunc (Money) JSONSchema() rsckit.Schema { return rsckit.Schema{\"type\": \"string\"} }\n(keywords the build reads: type, items, properties/required, additionalProperties, enum, anyOf).\nA backend FAILURE in development carries its trace: the thrown error's cause is\n\"Backend <Type>\" with the PHP/Go frames, and the stack says \"Caused in the backend\nby ...\" - read that before guessing (Laravel: app.debug; Go: CallbackHandler.Debug\nor RSC_DEBUG=1). Never in production.\nA failed rpc() in a server component: catch and check\nerror instanceof ServerAuthenticationError (401) / ServerAuthorizationError (403)\nfrom '@rsc-kit/core/errors'; rethrow anything else.\nreg.Register(name, func(ctx, args rsckit.Args) (any, error)) stays untyped.\nLaravel: rsc:host-manifest types each app/Rsc method from its PHP signature -\nint/float/string/bool params (nullable, defaults optional, variadic), a\nFormRequest first param from its rules(), and a result that is a scalar, a\nbacked enum, or a plain class with public typed properties (a data object ->\nan interface). array/Model/Collection/JsonSerializable stay unknown: give\nthose rpc<T>(). Return a data object, not an array, to get a typed result. Tests\nanswer the backend with createTestApp({ host }) - how_to testing.\n\nWriting or changing an ADAPTER: it must pass the conformance suite before it\nships - register the Conformance.* functions (read_guide your-own-backend,\n\"Before you ship\"), serve them, and run\nnpx -y -p @rsc-kit/core rsc-kit-conformance --endpoint <url>/__rsc/host-call --secret <s> --manifest rsc-host.json\n(Go: cmd/conformance; Laravel: tests/Conformance are fixtures to copy).\nBAP vs Inertia for a Laravel team: read_guide bap-vs-inertia.\n\nA backend in another language answers that ONE endpoint, POST /__rsc/host-call,\nand the renderer wires itself from two variables in .env: RSC_BACKEND (a\nLaravel app's APP_URL counts) and RSC_HOST_CALL_SECRET. Both or neither.\n\nA url the route tree does not own goes to the backend (/login, a webhook, an\nOAuth callback). One that it DOES match does not: a dynamic root route such as\n[team]/[app] matches every two-segment url, renders, says notFound() - and a page\nthat said that is never forwarded, so the backend's /gitlab/connect was the\napp's 404 (and sign-in looped) - Laravel's /login and /auth/* as much as Go's\nwhen the renderer is in front. Name the backend's prefixes, and they are\nforwarded BEFORE any page is asked: rscKit({ backendPaths: ['/auth', '/github',\n'/gitlab'] }). A prefix at a segment boundary (/auth, not /authors), every\nmethod; '/' and /_rsc are refused; a team called auth is then unreachable. Do NOT\nwrite catch-all route.ts files that forward by hand: that copies the forwarding.\nA named prefix is also a DOCUMENT LOAD in the browser, never a page: a Link,\nvisit() or redirect('/gitlab/connect') to it loads the document and is\nnever prefetched, so the backend route may bounce on to another site (an OAuth\nstart). NOT named, the router fetches it as a payload, the bounce to another\norigin is a hop a fetch cannot make, and the tap does nothing. Do NOT work around\nit with a client component that sets window.location.\n\nLaravel: composer require rsc-kit/laravel, then php artisan rsc:install. It\nruns rsc-kit init, which writes ONE vite.config.ts (laravel-vite-plugin is\nmoved aside - the renderer owns the frontend). Source is resources/js (the route tree is resources/js/app).\n\nrpc() is a global the renderer installs in its own process (never imported,\nnever in the browser bundle): one POST to the backend's host-call endpoint\nwith { function, args }, the secret and the visitor's cookie; the answer is\nthe return value as JSON, typed by rpc<T>(). Refusals arrive as their kind\n(422/401/403/redirect, or the status the backend gave: abort(429, 'Slow down')\nkeeps its 429 and its message), never a 500; sibling calls in one tick are batched\nand each resolves the moment the backend answers it.\nA BAP server bundle carries no database driver, ORM or auth library - the\nbackend owns those.\n\nReach PHP from a server component - rpc() is a global, typed in\n.rsc-kit/rsc-env.d.ts, server render only:\n\n // app/Rsc/Orders.php: public function recent(int $limit): array\n const orders = await rpc<Order[]>('Orders.recent', 5)\n\nThe call runs AS THE VISITOR (their cookie is forwarded; auth()->user() is\nthem). Refuse with attributes: #[Authenticated], #[Can('update', Order::class)],\n#[Middleware('throttle:60,1')]. A ValidationException lands on the form as\nvalidationErrors; Authentication/Authorization exceptions answer 401/403. An\naction shows an abort()'s message to the visitor as written, so give it one -\nabort(429, 'Slow down'); without one the form says \"Refused.\". Only a failure\nis replaced by onError's generic message.\n\nGuard a route in Laravel's vocabulary, no route declared in PHP:\n\n // resources/js/app/admin/middleware.ts\n export const middleware = ['auth', 'verified', 'can:update,post']\n\nMake one: php artisan make:rsc-action Orders --method=cancel --auth\n--can=update,Order --middleware=throttle:60,1 --revalidate=orders (--rpc for\nan rpc() class under app/Rsc; no --method = invokable; a slash nests,\nBilling/Invoices). Do NOT hand-write the attributes from memory; the command\nwrites the ones the registry reads, and it writes the manifest\n(rsc-host.json) too - a running dev server restarts on its own when it\nchanges, so the export is importable when the command returns.\nServer actions are classes in app/Rsc/Actions, found by reflection in PHP;\n\\`php artisan rsc:host-manifest\\` writes rsc-host.json - { actions,\nfunctions } - and vite.config.ts runs it as dev and every build start:\nrscKit({ hostManifest: { command: ['php', 'artisan', 'rsc:host-manifest'] } }).\nfunctions types rpc()'s first argument, so a misspelt name fails tsc. The\nbuild writes the \"use server\" stubs from actions - import ordersCancel from\nthe generated actions module in a client component. Rsc::revalidate('orders')\nin the action returns the re-rendered region with the answer.\n\nPer url: if the React tree has it, React renders it, otherwise Laravel does\n- including / : the welcome route in routes/web.php answers nothing while\nresources/js/app/page.tsx exists (the package registers the tree's urls from\nbootstrap/rsc/vite/routes.json after routes/web.php). Do NOT tell the user\nto delete the welcome route to make the page show; do not add Laravel routes\nfor React pages.\n\nphp artisan serve is one worker, which deadlocks the proxy - unless\nPHP_CLI_SERVER_WORKERS=4 in .env AND serve --no-reload (Laravel ignores\nthe variable otherwise). Herd, Valet, FPM, Octane are fine as they are. Production: put the renderer in front (bun\n.output/server/index.mjs with the app's .env), restrict /__rsc/host-call at\nthe web server.\n\nAny other language implements the same endpoint - request { function, args },\nreply { result | validationErrors | unauthenticated | unauthorized | redirect\n| error, revalidate }, answers '__rsc.middleware' with true or a refusal, and\na batch { calls: [...] } as application/x-ndjson - one line per call AS IT\nFINISHES, { index, status, ...reply }, in any order, flushed each time\n(X-Accel-Buffering: no) - or, less good, one JSON { replies: [...] } in\norder (every call then waits for the slowest). Calls issued in the same\nrender tick travel as one batch, so parallel reads are one backend request\nand a fast read still resolves while a slow sibling runs. Batches are\nrequired: any other answer fails every call in it (at most 50 per batch;\n413 for more is fine).\n\nFull guides: read_guide({ slug: 'backend-answered-pages' }), read_guide({ slug: 'laravel' }), read_guide({ slug: 'go' }), read_guide({ slug: 'your-own-backend' }).`,\n },\n {\n topic: 'startup',\n summary: 'Once-per-process setup and teardown, and request logging - src/instrumentation.ts is imported before any page, its register() awaited before the first request, its shutdown() called when the server stops, its onRequest() once each response is over',\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\nRequest logging: export onRequest() from the same file, and the engine calls it\nonce per request it answers (page, payload, action, route.ts) when the response\nis OVER - the last byte sent, or the visitor gone mid-stream:\n\n export const onRequestHeaders = ['cf-ray'] // the only headers it is given\n export function onRequest({ method, pathname, status, durationMs, aborted, error, headers }: ServedRequest) {}\n // ServedRequest from '@rsc-kit/core/request'\n\ndurationMs runs to the last byte; aborted is true when the visitor left\nmid-stream; error is set when the render failed. pathname has NO query string.\nDo NOT log from middleware.ts for this (it runs before the render, so it never\nsees the end), and do NOT wrap the server entry - it is generated. onRequest()\nis not awaited, and a throw from it never fails the page.\n\nA file in src/commands (how_to commands) does NOT import this one unless it\nexports \\`register = true\\` (the literal): without it the command runs from a\nbundle that leaves instrumentation.ts out, so none of its top level - env\nvalidation included - runs for it. With it, the file is imported and\nregister() runs first.\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 and its 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 and each cause on a [cause]: line (Drizzle's failed\nquery keeps the database's reason there - do NOT catch to re-log it), any other\nreturn (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's\ncode, but NOT instrumentation.ts unless it exports \\`register = true\\` (the\nliteral; the build reads it to pick the bundle): a queue drain should not fail\non the web's Stripe key. It validates what it imports - \\`import '@repo/env/worker'\\`\nin the command. With register = true, instrumentation.ts is imported (its env\nvalidated), register() runs first, and shutdown() after it returns, also after\nit 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.\nA compiled binary (bun run compile) carries those packages and writes them to\ndisk on its first start, to $RSC_KIT_RUNTIME_DIR/<hash>/ (default: beside the\nbinary), and loads them from there - so the binary needs a WRITABLE directory:\non a read-only filesystem set RSC_KIT_RUNTIME_DIR (e.g. /tmp/app). One\ndirectory is safe to share: a later start writes nothing, two at once do not\nclash, and each version has its own hash folder (one untouched for a day is\nremoved). What goes to disk: packages with a .node/.wasm (or os/cpu), packages\ndepending on them, their dependencies, and anything in serverExternalPackages\n(name a pure-JS package there when it reads files beside its own code); pure JS\nstays bundled. Compiling for another platform (--target=bun-linux-x64 on a Mac)\nneeds that platform's packages installed beside the build machine's:\nbun install --os=darwin --os=linux --cpu=arm64 --cpu=x64. Without them\n\\`bun run compile\\` (and compile:binary after that build) fails and names them;\ndo NOT work around it by dropping --target or copying node_modules next to the binary.\nThe compile step is two scripts: compile is \"vite build && bun run compile:binary\",\ncompile:binary is the bun build --compile alone, and .output/rsc-kit.json names\ncompile:binary so a deploy that has just built does not build again. Keep both;\ndo NOT put vite build back inside compile:binary.\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. A built server started\nwith no NODE_ENV sets the one it was built with in its own process before\nthe app loads, so process.env.NODE_ENV and env.NODE_ENV are 'production' at\nrun time - do NOT add NODE_ENV=production to a start command or a Dockerfile.\nThat makes rsc-kit's production checks run: an app using refreshOn sets\nRSC_SIGNING_SECRET or its live pages answer 500.\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. env.NODE_ENV is 'production' on a built\nserver with nothing set (the server sets it from the build's mode before the\napp loads), so its .default('development') is only a dev server's or a\nscript's. 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). A refresh is swapped in as a\ntransition: the section keeps what it showed until the new content is ready,\nnever its skeleton, so do NOT add a cache or a held copy to avoid the blink.\nWhat a refresh rendered lasts\nas long as the layout: a visit to another page under it keeps it, so a rename's\nnew name stays in the frame after the form navigates. 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"]}
|
package/guides/deployment.md
CHANGED
|
@@ -138,6 +138,14 @@ was *built* that way. Setting it at start time is a second source of truth and
|
|
|
138
138
|
the one that can disagree — and when it disagrees the failure is silent: every
|
|
139
139
|
page renders, and none of them hydrate.
|
|
140
140
|
|
|
141
|
+
A server started with nothing set gives its own process the mode it was built
|
|
142
|
+
in, before the app loads, so `process.env.NODE_ENV` is `production` at run time
|
|
143
|
+
too. That is what a typed `env.ts` built from a spread of `process.env` reads,
|
|
144
|
+
and before 0.30.36 it read nothing and fell to its schema's
|
|
145
|
+
`.default('development')`. It also means rsc-kit's own production checks run:
|
|
146
|
+
an app that uses `refreshOn` must set `RSC_SIGNING_SECRET`, or its live pages
|
|
147
|
+
answer 500.
|
|
148
|
+
|
|
141
149
|
The check is React's debug rows in the payload:
|
|
142
150
|
|
|
143
151
|
```sh
|
|
@@ -50,7 +50,9 @@ generation](/guides/static-generation).
|
|
|
50
50
|
React picks its build from it, and getting it wrong gives you a page that
|
|
51
51
|
renders perfectly and never hydrates. The build stamps the mode it ran in
|
|
52
52
|
into the server bundles, so a server is production because it was built that
|
|
53
|
-
way — `npm run start` needs no environment at all.
|
|
53
|
+
way — `npm run start` needs no environment at all. The process gets it too:
|
|
54
|
+
a server started with no `NODE_ENV` sets the one it was built with, so code
|
|
55
|
+
that reads `process.env` at run time agrees with the bundle.
|
|
54
56
|
</Aside>
|
|
55
57
|
|
|
56
58
|
## Compiling to a single binary
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rsc-kit/mcp",
|
|
3
|
-
"version": "0.30.
|
|
3
|
+
"version": "0.30.36",
|
|
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.
|
|
37
|
+
"create-rsc-kit": "^0.30.36",
|
|
38
38
|
"zod": "^3.25"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|