@rsc-kit/mcp 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/answers.d.ts +7 -0
  2. package/dist/answers.js +28 -0
  3. package/dist/answers.js.map +1 -1
  4. package/dist/bundleGuides.d.ts +27 -0
  5. package/dist/bundleGuides.js +138 -0
  6. package/dist/bundleGuides.js.map +1 -0
  7. package/dist/index.js +21 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/recipes.js +138 -17
  10. package/dist/recipes.js.map +1 -1
  11. package/dist/report.d.ts +13 -0
  12. package/dist/report.js +1 -1
  13. package/dist/report.js.map +1 -1
  14. package/guides/api-routes.md +168 -0
  15. package/guides/authorization.md +288 -0
  16. package/guides/caching.md +57 -0
  17. package/guides/coming-from-next.md +151 -0
  18. package/guides/connection.md +98 -0
  19. package/guides/edge-caching.md +159 -0
  20. package/guides/errors.md +109 -0
  21. package/guides/file-uploads.md +119 -0
  22. package/guides/fonts.md +117 -0
  23. package/guides/forms.md +528 -0
  24. package/guides/getting-started.md +132 -0
  25. package/guides/images.md +83 -0
  26. package/guides/index.json +187 -0
  27. package/guides/installation.md +338 -0
  28. package/guides/introduction.md +119 -0
  29. package/guides/mcp.md +113 -0
  30. package/guides/metadata.md +289 -0
  31. package/guides/navigation.md +84 -0
  32. package/guides/no-javascript.md +76 -0
  33. package/guides/offline.md +215 -0
  34. package/guides/ppr.md +181 -0
  35. package/guides/pwa.md +260 -0
  36. package/guides/queries.md +340 -0
  37. package/guides/quick-start.md +99 -0
  38. package/guides/react-compiler.md +153 -0
  39. package/guides/redirects.md +143 -0
  40. package/guides/response-headers.md +66 -0
  41. package/guides/route-interception.md +206 -0
  42. package/guides/routing.md +458 -0
  43. package/guides/sections.md +74 -0
  44. package/guides/server-actions.md +444 -0
  45. package/guides/static-generation.md +347 -0
  46. package/guides/testing.md +158 -0
  47. package/guides/third-party-scripts.md +105 -0
  48. package/guides/typed-routes.md +139 -0
  49. package/guides/url-validation.md +143 -0
  50. package/guides/validation.md +175 -0
  51. package/guides/view-transitions.md +120 -0
  52. package/package.json +4 -3
@@ -0,0 +1,347 @@
1
+ # Static generation
2
+
3
+ > Rendering pages ahead of time, and exporting a site of files.
4
+
5
+ Pages that do not need the request can be rendered once, at build time, and
6
+ served from disk — no render per request, no round trip to a database.
7
+
8
+ ## The build decides by rendering
9
+
10
+ Nothing declares whether a route is stored. The build renders each one with a
11
+ budget and watches what happens:
12
+
13
+ | Outcome | What the probe saw |
14
+ | --- | --- |
15
+ | The whole page | It rendered to completion. Stored and served from disk. |
16
+ | A shell | Something was still waiting when the budget expired, but the static parts had painted. The shell is stored; the rest is rendered per request. |
17
+ | A redirect | The route only redirects, so the redirect itself is stored — status and location. |
18
+ | Refused | Nothing painted before the page blocked. The build fails. |
19
+
20
+ The budget is 2 seconds, and `RSC_PPR_TIMEOUT_MS` changes it. A page whose data
21
+ resolves inside it is stored whole — including one that called `fetch()`, as
22
+ long as the answer arrived.
23
+
24
+ There is no way to opt out, and that is the point: a route the build cannot
25
+ store has its boundary in the wrong place. Put the part that waits inside
26
+ `<Suspense>`, or add a `loading.tsx` beside the page, and it becomes a shell.
27
+
28
+ <Aside type="caution" title="Stored means stored">
29
+ Whatever a page read at build time is in the file. If it should reflect the
30
+ current state of the world on every request, it needs to read the request —
31
+ `params`, `searchParams`, `headers()`, `cookies()` — which suspends, and puts
32
+ it behind a boundary rather than in the stored bytes.
33
+ </Aside>
34
+
35
+ ## Listing the URLs of a parameterised route
36
+
37
+ A route with a `[param]` cannot be frozen unless something says which values
38
+ exist. Export `generateStaticParams` from the page:
39
+
40
+ ```tsx title="src/app/direct/[slug]/page.tsx"
41
+ import { Suspense } from 'react'
42
+ import { allSlugs, findPost, SECRET } from '../../../data'
43
+ import type { Metadata } from '@rsc-kit/core/metadata'
44
+
45
+ // Which urls exist. The one thing the build cannot work out for itself — and
46
+ // the reason this route is frozen per url, while /posts/[slug], which declares
47
+ // nothing, is frozen once as a shell.
48
+ export function generateStaticParams() {
49
+ return allSlugs().map((slug) => ({ slug }))
50
+ }
51
+
52
+ export const metadata: Metadata = { title: 'Direct import' }
53
+
54
+ async function Body({ params }: { params: Promise<{ slug: string }> }) {
55
+ const { slug } = await params
56
+ const post = await findPost(slug)
57
+
58
+ // Referenced so the bundler cannot tree-shake the module away — the point is
59
+ // that it is in the server graph and not the client one.
60
+ const proof = SECRET.length
61
+
62
+ if (!post) return <h1>No such post: {slug}</h1>
63
+
64
+ return (
65
+ <>
66
+ <h1>{post.title}</h1>
67
+ <p>{post.body}</p>
68
+ <p className="muted">secret length on the server: {proof}</p>
69
+ </>
70
+ )
71
+ }
72
+
73
+ export default function DirectPage({ params }: { params: Promise<{ slug: string }> }) {
74
+ return (
75
+ <Suspense fallback={<p className="muted">Loading…</p>}>
76
+ <Body params={params} />
77
+ </Suspense>
78
+ )
79
+ }
80
+ ```
81
+
82
+ Each object it returns is one URL to build. For a route with several params,
83
+ return every combination:
84
+
85
+ ```tsx title="src/app/blog/[year]/[slug]/page.tsx"
86
+ export function generateStaticParams() {
87
+ return [
88
+ { year: '2025', slug: 'hello-world' },
89
+ { year: '2025', slug: 'getting-started' },
90
+ ];
91
+ }
92
+ ```
93
+
94
+ A route that lists nothing is rendered on demand. That is a decision, not a
95
+ failure — `/posts/[slug]` in the example app declares no params precisely so
96
+ the two paths can be compared side by side.
97
+
98
+ ## Running the prerender
99
+
100
+ `vite build` does it, at the end, once every bundle exists — prerendering is
101
+ the app rendering itself, so it needs the thing the build just produced.
102
+
103
+ ```bash
104
+ bun run build
105
+ # ○ /
106
+ # ○ /about
107
+ # ◔ /account
108
+ #
109
+ # 2 stored, 1 shell
110
+ ```
111
+
112
+ ### Turning it off
113
+
114
+ ```ts title="vite.config.ts"
115
+ rscKit({ sourceDir: 'src', prerender: false });
116
+ ```
117
+
118
+ **Try [`connection()`](/guides/connection) first.** It is almost always the
119
+ better answer, and this is almost always too big a hammer.
120
+
121
+ Prerendering **runs your application code**, so it needs whatever that code
122
+ needs — a page that queries a database needs that database reachable from the
123
+ build. When it is not, marking that one query is a smaller and more accurate
124
+ statement than switching prerendering off for every route in the app:
125
+
126
+ ```tsx
127
+ await connection()
128
+
129
+ const rows = await db.query('select * from orders')
130
+ ```
131
+
132
+ The build then skips that work, stores everything around it, and the page still
133
+ gets a shell. Turning prerendering off stores nothing, anywhere, and every page
134
+ renders for every visitor forever.
135
+
136
+ Reach for this switch when the reason is about the *build itself* rather than
137
+ any page:
138
+
139
+ Two other reasons: a large site adds real time to every build, and a deploy
140
+ that has to run migrations first may want to prerender at a later stage
141
+ entirely. `RSC_PRERENDER=0` does the same thing for a host that drives the
142
+ build out of process and cannot pass an option.
143
+
144
+ It is off in watch mode regardless — a rebuild on every keystroke that also
145
+ re-renders every route is not a feedback loop anyone wants.
146
+
147
+ ### Doing it yourself
148
+
149
+ `prerender` is also a plain function, exported from `@rsc-kit/core/prerender`,
150
+ so it runs wherever your build does. `write` is a callback rather than a
151
+ directory, because not every place this runs has a filesystem — `writeTo` is
152
+ the `node:fs` implementation of it, and a platform without one passes its own.
153
+
154
+ You should not need it. The build calls it for you, with the engine bundle it
155
+ just produced — a path only the build knows, since Nitro builds the rsc
156
+ environment under `node_modules`.
157
+
158
+ ## What gets written
159
+
160
+ Per route, keyed by its URL — `/` becomes `index`, `/docs/install` becomes
161
+ `docs/install`:
162
+
163
+ | File | For |
164
+ | --- | --- |
165
+ | `{key}.html` | A full page load. |
166
+ | `{key}.flight` | An SPA navigation that replaces the whole document. |
167
+ | `{key}.seg1.flight`, `{key}.seg2.flight`, … | An SPA navigation that keeps that many layouts. |
168
+ | `{key}.meta.json` | The client chunks and build version the payload belongs to. |
169
+ | `{key}.ppr.html` | A shell, stored per route *pattern* rather than per URL. |
170
+
171
+ The `.seg{n}` variants are what make a prerendered route participate in partial
172
+ navigation. Without them every arrival at a frozen page would replace the
173
+ document root, unmounting the pages retained behind it — the form you were
174
+ filling in would not survive going back to it.
175
+
176
+ ## Serving them
177
+
178
+ The host checks for a frozen file before it matches a route. `prerendered` is
179
+ the source it checks, and like `write` it is a callback:
180
+
181
+ The generated server does this for you: the build freezes pages into
182
+ `.output/server/rsc-static`, and the entry reads them from beside itself.
183
+ Nothing found, and the request falls through to a live render.
184
+
185
+ ## A value that must not be frozen
186
+
187
+ A stored page keeps whatever it rendered, which includes whatever the clock
188
+ said at build time. So this renders once, during the build, and serves that
189
+ same instant to everyone until the next one:
190
+
191
+ ```tsx
192
+ export default function Page() {
193
+ // Frozen. Not "a few seconds stale" — the moment your CI ran.
194
+ return <p>Rendered at {new Date().toISOString()}</p>;
195
+ }
196
+ ```
197
+
198
+ The build says so rather than leaving you to notice:
199
+
200
+ ```
201
+ ○ /
202
+ ⚠ froze new Date() — a stored page keeps whatever that returned at build
203
+ time. If it should differ per visitor, await connection() so the page
204
+ renders per request; if only the browser needs it, use(browser()) keeps
205
+ it out of the build entirely.
206
+ ```
207
+
208
+ `new Date()`, `Date.now()`, `Math.random()` and `crypto.randomUUID()` are all
209
+ watched.
210
+
211
+ <Aside type="caution" title="Suspense alone does not fix this">
212
+ Wrapping it in a boundary changes nothing: prerendering renders straight
213
+ through a component that never awaits, so the value is captured exactly as
214
+ before — same `○`, same warning. A boundary becomes a hole only when
215
+ something inside it *waits* for what the build cannot finish — a read that
216
+ outlasts the build's budget, or a page that has said `await connection()`.
217
+ </Aside>
218
+
219
+ Moving it into a client component makes it worse, not better. Hydration is not
220
+ React attaching handlers to existing markup — it *runs* the component, because
221
+ that is the only way it learns what the tree should be.
222
+
223
+ So the body executes twice, once where the HTML came from and once in the
224
+ browser, and a clock read inside gives two different answers.
225
+
226
+ Where the value comes from is what decides it, not whether the component is a
227
+ client one:
228
+
229
+ | the value is | result |
230
+ | --- | --- |
231
+ | read in a server component | frozen at build time, and the build warns |
232
+ | read in a client component's body | produced twice, and the two disagree |
233
+ | computed on the server, passed as a prop | travels in the payload, so both renders read the same string |
234
+ | read only in the browser | nothing on the server to disagree with |
235
+
236
+ The third row is the ordinary answer for anything the server can decide. It
237
+ does hydrate the client component, and there is no mismatch, because the value
238
+ arrived rather than being recomputed. It is still frozen with the page, which
239
+ is fine for a build stamp and wrong for a clock.
240
+
241
+ ## Rendering something in the browser only
242
+
243
+ For a value that is genuinely per-visitor and needs no server — a clock,
244
+ `localStorage`, a map, an editor — say so, and React will skip the component on
245
+ the server:
246
+
247
+ ```tsx
248
+ 'use client';
249
+
250
+ import { Suspense, use, useState } from 'react';
251
+ import { browser } from 'react-dom';
252
+
253
+ function SavedDraft() {
254
+ use(browser('the draft lives in localStorage'));
255
+
256
+ const [draft] = useState(() => localStorage.getItem('draft') ?? '');
257
+
258
+ return <p>{draft || 'Nothing saved yet'}</p>;
259
+ }
260
+ ```
261
+
262
+ The page it sits on stays frozen at build time. React stops the server render
263
+ at that component and puts the nearest Suspense boundary's fallback into the
264
+ HTML; everything around it rendered normally, so there is still a finished
265
+ document to store.
266
+
267
+ ```tsx
268
+ <Suspense fallback={<p>Loading draft…</p>}>
269
+ <SavedDraft />
270
+ </Suspense>
271
+ ```
272
+
273
+ That fallback is what visitors see in the first paint, and the real thing
274
+ replaces it once the browser renders. There is no mismatch to have, because
275
+ only one side ever ran.
276
+
277
+ Three things to know:
278
+
279
+ - **The Suspense boundary is required.** Without one, the server render fails
280
+ rather than degrading.
281
+ - **It has to be a client component.** `browser()` is about skipping the server
282
+ render of something that will render in the browser; a server component has
283
+ no browser render to fall back to.
284
+ - **`browser()` alone does nothing.** Pass it to `use()`. Do not throw it.
285
+
286
+ The `reason` is optional and only ever read on the server, where it shows up in
287
+ the renderer's bailout callback. Give it one anyway — it is the sentence the
288
+ next person needs. Pass a function if building it is expensive.
289
+
290
+ <Aside type="note" title="This replaced a workaround">
291
+ The old answer was an empty first render plus a `useEffect`, which worked by
292
+ making both renders agree on nothing. `browser()` lands the same outcome with
293
+ the fallback in the HTML instead of a blank, and says in the component why.
294
+ It is in `react-dom` 19.3.
295
+
296
+ This package ships no `ClientOnly` of its own, and now never will — the
297
+ boundary was always React's to draw.
298
+ </Aside>
299
+
300
+ ## Exporting a site
301
+
302
+ Build with `output: 'export'` and the build writes a directory a static host
303
+ can serve with no origin at all:
304
+
305
+ ```bash
306
+ RSC_OUTPUT=export npm run build
307
+ # Exported 9 pages to dist
308
+ ```
309
+
310
+ It refuses unless every route came out static. A shell on a static host is a
311
+ page that loads and then stays empty forever, because there is nothing running
312
+ to fill it in. `RSC_EXPORT_FORCE=1` writes the site anyway and reports what it
313
+ left out, which is how you move an app towards being exportable.
314
+
315
+ A route that only redirects is written as a meta refresh, which is the one
316
+ redirect every static host performs without being configured.
317
+
318
+ ## Customising the document
319
+
320
+ There is no separate shell template. The root `layout.tsx` renders the whole
321
+ document, and the build injects the bootstrap script and stylesheet links into
322
+ it. The same layout serves streamed and frozen pages.
323
+
324
+ ```tsx title="src/app/layout.tsx"
325
+ import './styles.css';
326
+
327
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
328
+ return (
329
+ <html lang="en">
330
+ <head>
331
+ <meta charSet="utf-8" />
332
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
333
+ </head>
334
+ <body>{children}</body>
335
+ </html>
336
+ );
337
+ }
338
+ ```
339
+
340
+ <Aside type="tip" title="Fonts and global CSS belong in the root layout">
341
+ The root layout renders once and survives every SPA navigation, so its
342
+ `<link>` tags load a single time. The same tags in a nested layout or a page
343
+ are re-injected on every navigation.
344
+ </Aside>
345
+
346
+ Per-page titles and meta tags come from the page, not the layout — see
347
+ [Page metadata](/guides/metadata).
@@ -0,0 +1,158 @@
1
+ # Testing
2
+
3
+ > Three tiers, and the one thing that still needs a browser.
4
+
5
+ Almost everything here is a function, and functions are tested by calling them.
6
+ Any test runner works — Bun's, Vitest, Jest — because nothing below needs a
7
+ runtime of ours. The examples use `bun:test`.
8
+
9
+ ## Actions, queries and api routes are functions
10
+
11
+ `"use server"` is a directive for the bundler. In a test file it is a string, and
12
+ the function it marks is importable:
13
+
14
+ ```ts title="tests/actions.test.ts"
15
+ import { placeOrder } from '../src/actions'
16
+ import { getListings } from '../src/queries'
17
+
18
+ test('placeOrder accepts an item', async () => {
19
+ expect(await placeOrder('a rubber duck')).toEqual({ ok: true })
20
+ })
21
+
22
+ test('getListings answers with a list', async () => {
23
+ expect(await getListings('stay')).toBeInstanceOf(Array)
24
+ })
25
+ ```
26
+
27
+ An action built on the [action client](/guides/authorization/) runs its whole
28
+ middleware chain when called, so a check that refuses a stranger is testable by
29
+ calling it as one. It **returns** its failures, so assert on the result:
30
+
31
+ ```ts
32
+ const result = await createPost({ title: '' })
33
+
34
+ expect(result.validationErrors).toEqual({ title: ['too short'] })
35
+ ```
36
+
37
+ An api route is the same. Hand it a `Request` and the context the engine would:
38
+
39
+ ```ts title="tests/api.test.ts"
40
+ import { GET } from '../src/app/api/greet/[name]/route'
41
+
42
+ test('greets by name', async () => {
43
+ const res = await GET(new Request('https://app.test/api/greet/ada'), {
44
+ params: Promise.resolve({ name: 'ada' }), // a promise, as the engine gives it
45
+ })
46
+
47
+ expect(await res.json()).toEqual({ greeting: 'Hello, ada' })
48
+ })
49
+ ```
50
+
51
+ :::note[This is more than Next offers]
52
+ Next's advice for server actions is end-to-end, because the interesting part
53
+ there is the round trip. Here the function *is* the interesting part — the
54
+ validation, the middleware, the authorisation — and it is a unit test.
55
+ :::
56
+
57
+ ### Reading the request
58
+
59
+ `cookies()`, `headers()` and the rest read from a scope the host opens per
60
+ request. In a test there is no host, so open it yourself:
61
+
62
+ ```ts title="tests/session.test.ts"
63
+ import { withRequest } from '@rsc-kit/core/request'
64
+ import { currentUser } from '../src/session'
65
+
66
+ test('a signed-in cookie is a user', async () => {
67
+ const user = await withRequest(
68
+ new Request('https://app.test/', { headers: { Cookie: 'session=abc' } }),
69
+ currentUser,
70
+ )
71
+
72
+ expect(user?.name).toBe('Ada')
73
+ })
74
+
75
+ test('and no cookie is nobody', async () => {
76
+ expect(await withRequest(new Request('https://app.test/'), currentUser)).toBeNull()
77
+ })
78
+ ```
79
+
80
+ The scope is per call, so two requests in flight in the same test do not see
81
+ each other's cookies.
82
+
83
+ ## The whole app, as it is deployed
84
+
85
+ The tier between a function and a browser, and the one that is ours to offer:
86
+ the app as `Request → Response`, through the real router, the real middleware,
87
+ the real api routes and the pages the build stored — with no port and no
88
+ process.
89
+
90
+ ```ts title="tests/app.test.ts"
91
+ import { createTestApp } from '@rsc-kit/core/testing'
92
+
93
+ const app = await createTestApp()
94
+
95
+ test('a stored page is served', async () => {
96
+ const res = await app.fetch('/orders')
97
+
98
+ expect(res.status).toBe(200)
99
+ })
100
+
101
+ test('a guarded page turns a stranger away', async () => {
102
+ const res = await app.fetch('/admin', { redirect: 'manual' })
103
+
104
+ expect(res.status).toBe(307)
105
+ expect(res.headers.get('Location')).toBe('/login')
106
+ })
107
+
108
+ test('a method a route does not export is 405', async () => {
109
+ const res = await app.fetch('/api/health', { method: 'DELETE' })
110
+
111
+ expect(res.headers.get('Allow')).toContain('GET')
112
+ })
113
+ ```
114
+
115
+ This is where a route that renders fine and is served wrong shows up: a guard
116
+ that never ran, a stored page that should not have been, a `404` that came back
117
+ `200`.
118
+
119
+ `createTestApp()` builds when your source is newer than the last build, and not
120
+ otherwise — the first run pays for it, the rest do not, and an edit is picked
121
+ up. It runs your own `vite build`, so what is tested is what ships. Pass
122
+ `{ build: false }` in a ci step that already built.
123
+
124
+ One build and one loaded module per test run, shared across files. That is both
125
+ the fast path and the correct one: two copies of the server bundle in one
126
+ process would be two client-reference registries.
127
+
128
+ ## Components
129
+
130
+ `<Form>`, `useField`, `useOnline` and the rest are client components. Test them
131
+ with React's own tools and any DOM — the package's own suite uses `happy-dom`
132
+ with `react-dom/client` and `act`, and so can yours. Testing Library works the
133
+ same way.
134
+
135
+ ## What still needs a browser
136
+
137
+ One thing: **a server action called over the wire.** The function is testable
138
+ directly, and every url is testable through `createTestApp()` — but the wire
139
+ call carries an id that React keeps private, so a test holding the source
140
+ function cannot address the built one.
141
+
142
+ That is the same limit Next has, and it is narrower here: it is the encoding of
143
+ one round trip, not the action. Everything the action *does* is a unit test
144
+ above. For the round trip itself, and for anything that depends on hydration —
145
+ `useField` re-rendering, a navigation, an optimistic update reverting — use
146
+ Playwright against `vite preview`, which serves the real build.
147
+
148
+ ## What to test where
149
+
150
+ | what | how |
151
+ | --- | --- |
152
+ | an action's logic, validation, middleware | call it |
153
+ | a query | call it |
154
+ | an api route | call `GET`/`POST` with a `Request` |
155
+ | anything that reads cookies or headers | `withRequest()` |
156
+ | routing, guards, stored pages, status codes | `createTestApp().fetch()` |
157
+ | a client component | React + a DOM |
158
+ | an action over the wire, hydration, navigation | Playwright |
@@ -0,0 +1,105 @@
1
+ # Third-party scripts
2
+
3
+ > Analytics, tag managers and widgets — and why there is no Script component.
4
+
5
+ Write the tag. React 19 does the rest, and it does the parts Next's `<Script>`
6
+ component existed for.
7
+
8
+ ## An external script
9
+
10
+ ```tsx title="src/app/layout.tsx"
11
+ export default function RootLayout({ children }) {
12
+ return (
13
+ <html>
14
+ <body>
15
+ {children}
16
+ <script async src="https://www.clarity.ms/tag/abc123" />
17
+ </body>
18
+ </html>
19
+ )
20
+ }
21
+ ```
22
+
23
+ Rendered from a server component, React **hoists** it into `<head>` and
24
+ **deduplicates** it — render the same `src` in three components and one tag
25
+ goes out. `async` means it never blocks the page. That is `afterInteractive`,
26
+ without an import.
27
+
28
+ ## An inline snippet
29
+
30
+ Most analytics ship one — a few lines that stub a global and load the real
31
+ thing:
32
+
33
+ ```tsx title="src/app/layout.tsx"
34
+ <script
35
+ id="ms-clarity"
36
+ dangerouslySetInnerHTML={{
37
+ __html: `(function(c,l,a,r,i,t,y){c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
38
+ t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
39
+ y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
40
+ })(window, document, "clarity", "script", "abc123");`,
41
+ }}
42
+ />
43
+ ```
44
+
45
+ It renders where you wrote it and runs during parse, **before hydration**. For
46
+ a snippet whose job is to start recording as early as possible, that is the
47
+ earlier of the two moments and the one its authors intended. Next's
48
+ `Script` with `strategy="afterInteractive"` would have made it later.
49
+
50
+ :::note[Put it in the root layout]
51
+ The root layout renders once and is kept across navigations, so the snippet
52
+ runs once. In a *page*, a client-side navigation to it re-renders the tag, and
53
+ whether the browser runs a script inserted that way is not something to rely on
54
+ either way. Site-wide scripts go in the layout; that is where they belong
55
+ anyway.
56
+ :::
57
+
58
+ ## Porting from Next
59
+
60
+ | Next's `Script strategy` | here |
61
+ | --- | --- |
62
+ | `beforeInteractive` | `<script src>` without `async` — the browser blocks on it, which is what that strategy meant |
63
+ | `afterInteractive` | `<script async src>`, or the inline snippet above |
64
+ | `lazyOnload` | the effect below, inside `requestIdleCallback` |
65
+ | `worker` | not supported — that was Partytown, a separate project |
66
+
67
+ The `id` prop carries across unchanged. `onLoad` and `onReady` are the one
68
+ thing that needs a component, because they need to run in the browser:
69
+
70
+ ## When a script has to run after hydration
71
+
72
+ Rare, and specific: a script that touches DOM React rendered, and would find it
73
+ missing if it ran during parse. A ten-line client component covers it, and it is
74
+ yours rather than ours because there is nothing to abstract:
75
+
76
+ ```tsx title="src/components/AfterHydration.tsx"
77
+ 'use client'
78
+
79
+ import { useEffect } from 'react'
80
+
81
+ export function AfterHydration({ src, onLoad }: { src: string; onLoad?: () => void }) {
82
+ useEffect(() => {
83
+ const tag = document.createElement('script')
84
+
85
+ tag.src = src
86
+ tag.async = true
87
+ if (onLoad) tag.onload = onLoad
88
+
89
+ document.head.append(tag)
90
+
91
+ return () => tag.remove()
92
+ }, [src, onLoad])
93
+
94
+ return null
95
+ }
96
+ ```
97
+
98
+ Wrap the body in `requestIdleCallback` and it is `lazyOnload`.
99
+
100
+ ## Why nothing ships for this
101
+
102
+ A `Script` component would be a wrapper around a `script` tag whose two
103
+ useful behaviours — hoisting and deduplication — React already provides. The
104
+ package's own tests pin that React does, so the day it stops, this page is
105
+ what changes rather than your app.