@rsc-kit/mcp 0.13.1 → 0.15.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 (47) hide show
  1. package/dist/answers.d.ts +7 -0
  2. package/dist/answers.js +26 -0
  3. package/dist/answers.js.map +1 -1
  4. package/dist/bundleGuides.d.ts +22 -0
  5. package/dist/bundleGuides.js +130 -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 +239 -0
  10. package/dist/recipes.js.map +1 -1
  11. package/dist/report.d.ts +11 -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/connection.md +98 -0
  18. package/guides/edge-caching.md +159 -0
  19. package/guides/errors.md +109 -0
  20. package/guides/file-uploads.md +119 -0
  21. package/guides/fonts.md +117 -0
  22. package/guides/forms.md +528 -0
  23. package/guides/images.md +83 -0
  24. package/guides/index.json +162 -0
  25. package/guides/mcp.md +113 -0
  26. package/guides/metadata.md +289 -0
  27. package/guides/navigation.md +84 -0
  28. package/guides/no-javascript.md +39 -0
  29. package/guides/offline.md +215 -0
  30. package/guides/ppr.md +181 -0
  31. package/guides/pwa.md +260 -0
  32. package/guides/queries.md +340 -0
  33. package/guides/react-compiler.md +153 -0
  34. package/guides/redirects.md +143 -0
  35. package/guides/response-headers.md +66 -0
  36. package/guides/route-interception.md +206 -0
  37. package/guides/routing.md +458 -0
  38. package/guides/sections.md +74 -0
  39. package/guides/server-actions.md +444 -0
  40. package/guides/static-generation.md +347 -0
  41. package/guides/testing.md +158 -0
  42. package/guides/third-party-scripts.md +105 -0
  43. package/guides/typed-routes.md +139 -0
  44. package/guides/url-validation.md +143 -0
  45. package/guides/validation.md +175 -0
  46. package/guides/view-transitions.md +120 -0
  47. package/package.json +4 -3
@@ -0,0 +1,528 @@
1
+ # Forms
2
+
3
+ > Progressive forms, pending state and validation errors.
4
+
5
+ `<Form>` submits to a server action: a component
6
+ that handles the state for you, and a hook for when you want to hold it
7
+ yourself. Both cover validation errors, pending state, optimistic updates and
8
+ GET-form navigation.
9
+
10
+ ## The `<Form>` component
11
+
12
+ The simplest way to handle forms. Works without any hooks — just pass a server action and use the render-prop for pending state and errors.
13
+
14
+ ```tsx title="TodoForm.tsx"
15
+ "use client";
16
+
17
+ import { Form } from "@rsc-kit/core/form";
18
+ import { addTodo } from "./actions";
19
+
20
+ type FormValues = { title: string };
21
+
22
+ export default function TodoForm() {
23
+ return (
24
+ <Form<FormValues> action={addTodo}>
25
+ {({ pending, error }) => (
26
+ <>
27
+ <input name="title" placeholder="What needs to be done?" />
28
+ {error('title') && <span className="text-red-500">{error('title')}</span>}
29
+ <button disabled={pending}>
30
+ {pending ? 'Adding...' : 'Add Todo'}
31
+ </button>
32
+ </>
33
+ )}
34
+ </Form>
35
+ );
36
+ }
37
+ ```
38
+
39
+ The generic type parameter `<FormValues>` gives you autocomplete on `error()` and typed form data throughout the component.
40
+
41
+ ### Props
42
+
43
+ ```text
44
+ action — server action function (POST) or URL string (GET)
45
+ method — "get" | "post" (defaults to "post" for functions, "get" for strings)
46
+ resetOnSuccess — auto-reset form on success (default: true)
47
+ optimistic — callback for optimistic updates, called inside the transition
48
+ onSuccess — called with the action result on success
49
+ onError — called with validation errors on 422
50
+ onSubmit — called before submit, return false to cancel
51
+ prefetch — "hover" (default) | "mount" | "none" (GET forms only)
52
+ replace — replace history state (GET forms)
53
+ preserveScroll — keep scroll position (GET forms)
54
+ ```
55
+
56
+ ### useFormStatus
57
+
58
+ Nested components can access form state via context, without prop drilling:
59
+
60
+ ```tsx title="SubmitButton.tsx"
61
+ "use client";
62
+
63
+ import { useFormStatus } from "@rsc-kit/core/form";
64
+
65
+ export function SubmitButton() {
66
+ const { pending } = useFormStatus();
67
+
68
+ return (
69
+ <button type="submit" disabled={pending}>
70
+ {pending ? 'Saving...' : 'Save'}
71
+ </button>
72
+ );
73
+ }
74
+ ```
75
+
76
+ ---
77
+
78
+ ## Validation errors
79
+
80
+ When a submit fails validation, `<Form>` fills `errors`
81
+ themselves — there is no `try`/`catch` to write, and no state to hold. `errors`
82
+ is `Partial<Record<keyof T, string[]>>`: each field maps to its messages, and
83
+ `error('title')` returns the first one.
84
+
85
+ Where those errors come from depends on the host, and the components do not
86
+ care. See [Validation](/guides/validation).
87
+
88
+ An action can also name a field itself, for a refusal no schema could know
89
+ about. Through the [action client](/guides/server-actions#failing-on-something-a-schema-cannot-know)
90
+ `fieldErrors` arrives with the handler's arguments, typed to its input. A plain
91
+ `"use server"` function has no input type to draw on, so it imports the same
92
+ thing untyped:
93
+
94
+ ```ts
95
+ 'use server';
96
+
97
+ import { fieldErrors } from '@rsc-kit/core/action';
98
+
99
+ export async function addTodo(formData: FormData) {
100
+ const title = String(formData.get('title'));
101
+
102
+ if (await exists(title)) return fieldErrors({ title: 'Already on the list' });
103
+
104
+ await save(title);
105
+ }
106
+ ```
107
+
108
+ Either way, write `return fieldErrors(…)`. The form renders it under the field
109
+ you named, exactly as it would a schema failure.
110
+
111
+ ---
112
+
113
+ ## Optimistic updates
114
+
115
+ Optimistic updates go through React's `useOptimistic`. The callback runs inside the transition, so React reverts it automatically on error.
116
+
117
+ ```tsx title="TodoList.tsx"
118
+ "use client";
119
+
120
+ import { useOptimistic } from "react";
121
+ import { Form } from "@rsc-kit/core/form";
122
+ import { addTodo } from "./actions";
123
+
124
+ type Todo = { id: number; title: string; done: boolean };
125
+
126
+ export default function TodoList({ todos }: { todos: Todo[] }) {
127
+ const [optimisticTodos, addOptimistic] = useOptimistic(
128
+ todos,
129
+ (state, newTodo: Todo) => [...state, newTodo]
130
+ );
131
+
132
+ return (
133
+ <div>
134
+ <ul>
135
+ {optimisticTodos.map((todo) => (
136
+ <li key={todo.id}>{todo.title}</li>
137
+ ))}
138
+ </ul>
139
+
140
+ <Form
141
+ action={addTodo}
142
+ optimistic={(data) =>
143
+ addOptimistic({ id: Date.now(), title: data.title as string, done: false })
144
+ }
145
+ >
146
+ <input name="title" />
147
+ <button>Add</button>
148
+ </Form>
149
+ </div>
150
+ );
151
+ }
152
+ ```
153
+
154
+ ## Search and filter forms
155
+
156
+ When `action` is a URL string, the form navigates via RSC instead of doing a full page reload. Form fields are serialized as query parameters. Supports prefetching for instant navigation.
157
+
158
+ ```tsx title="SearchForm.tsx"
159
+ "use client";
160
+
161
+ import { Form } from "@rsc-kit/core/form";
162
+
163
+ export default function SearchForm() {
164
+ return (
165
+ <Form action="/search" method="get" prefetch="hover">
166
+ <input name="q" placeholder="Search..." />
167
+ <select name="sort">
168
+ <option value="relevance">Relevance</option>
169
+ <option value="date">Date</option>
170
+ </select>
171
+ <button>Search</button>
172
+ </Form>
173
+ );
174
+ }
175
+ ```
176
+
177
+ This navigates to `/search?q=hello&sort=date` via SPA navigation. The nearest Suspense boundary streams in the results. With `prefetch="hover"`, hovering the submit button pre-warms the base URL for instant feedback.
178
+
179
+ ## Using it with shadcn/ui
180
+
181
+ It works, and mostly by doing nothing. `<Form>` reads a native `FormData`, so
182
+ any component that ends up rendering a real form control is already compatible:
183
+
184
+ ```tsx
185
+ <Form action={createPost} schema={schema}>
186
+ {({ pending, errors }) => (
187
+ <>
188
+ <Label htmlFor="title">Title</Label>
189
+ <Input id="title" name="title" />
190
+ {errors.title?.[0] && <p className="text-destructive">{errors.title[0]}</p>}
191
+
192
+ <Select name="kind">
193
+ <SelectTrigger><SelectValue /></SelectTrigger>
194
+ <SelectContent>
195
+ <SelectItem value="post">Post</SelectItem>
196
+ </SelectContent>
197
+ </Select>
198
+
199
+ <Checkbox name="draft" />
200
+
201
+ <Button disabled={pending}>{pending ? 'Saving…' : 'Save'}</Button>
202
+ </>
203
+ )}
204
+ </Form>
205
+ ```
206
+
207
+ `Input`, `Textarea`, `Button` and `Label` are styled native elements, so `name`
208
+ does what it always does.
209
+
210
+ **`Select`, `Checkbox`, `Switch` and `RadioGroup` also work** — they are Radix
211
+ underneath, and Radix renders a hidden native control whenever you give it a
212
+ `name`, for exactly this. Omit the `name` and it is invisible to the form; that
213
+ is the only thing to remember.
214
+
215
+ :::caution[Not shadcn's own `<Form>`]
216
+ shadcn's `<Form>`, `<FormField>` and `<FormControl>` are wrappers around
217
+ [react-hook-form](https://react-hook-form.com), which is a different system for
218
+ the same job — its own state, its own validation, its own submit. Use one or
219
+ the other, not both.
220
+
221
+ Ours gives you the field errors the *server* returned, which is the half a
222
+ client-side library cannot do.
223
+ :::
224
+
225
+ ### shadcn's `Field` components
226
+
227
+ The newer `Field`, `FieldLabel`, `FieldError` and `FieldGroup` are plain
228
+ presentational components — they take props rather than reading a form
229
+ library's context, which is what the older `<FormField>` did. So they work here
230
+ directly:
231
+
232
+ ```tsx
233
+ <Form action={reportBug} schema={formSchema}>
234
+ {({ pending, errors }) => (
235
+ <FieldGroup>
236
+ <Field data-invalid={!!errors.title}>
237
+ <FieldLabel htmlFor="title">Bug title</FieldLabel>
238
+ <Input id="title" name="title" aria-invalid={!!errors.title} />
239
+ <FieldDescription>Keep it short and specific.</FieldDescription>
240
+ <FieldError errors={errors.title?.map((message) => ({ message }))} />
241
+ </Field>
242
+
243
+ <Button type="submit" disabled={pending}>
244
+ {pending ? 'Sending…' : 'Submit'}
245
+ </Button>
246
+ </FieldGroup>
247
+ )}
248
+ </Form>
249
+ ```
250
+
251
+ `FieldError` takes `Array<{ message?: string }>`, and our `errors` are
252
+ `string[]` per field — hence the one `map`. Everything else is the same markup
253
+ you would write with any other form library.
254
+
255
+ The difference is where the errors came from. With TanStack Form or
256
+ react-hook-form those are the *client's* validation; here they are the client's
257
+ **and** whatever the server sent back, in the same object, because a refused
258
+ action returns its fields rather than throwing them away.
259
+
260
+ ### Setting the values
261
+
262
+ The fields are uncontrolled, so an initial value is `defaultValue` — React's
263
+ own, nothing of ours:
264
+
265
+ ```tsx
266
+ <Input id="title" name="title" defaultValue={post.title} />
267
+ ```
268
+
269
+ After a refused submit the values are still there, because the DOM kept them:
270
+ nothing re-rendered the inputs, so nobody typed twice. That is the upside of
271
+ not owning the value.
272
+
273
+ The exception is a submit that happened **before hydration**, where the page
274
+ genuinely reloads. Then the server renders the page again, and putting the
275
+ values back is the server's job — return them from the action and render them
276
+ as `defaultValue`.
277
+
278
+ ### Lists of values
279
+
280
+ A repeated name is an array:
281
+
282
+ ```tsx
283
+ <Checkbox name="tags" value="react" />
284
+ <Checkbox name="tags" value="vite" />
285
+ // → { tags: ['react', 'vite'] }
286
+ ```
287
+
288
+ With one ticked that is `'react'`, a string — which no `z.array()` will accept.
289
+ So for anything that is a list by nature, end the name in `[]` and it is always
290
+ an array:
291
+
292
+ ```tsx
293
+ <Checkbox name="tags[]" value="react" />
294
+ // → { tags: ['react'] }
295
+ ```
296
+
297
+ The brackets are dropped from the key, and it is the same spelling a value is
298
+ serialised back into — so a list survives a round trip.
299
+
300
+ ### Nested and repeating groups
301
+
302
+ Names that describe a shape build it:
303
+
304
+ ```tsx
305
+ <input name="address.city" /> // → { address: { city } }
306
+ <input name="items[0].name" /> // → { items: [{ name }] }
307
+ <input name="items[0][name]" /> // the same field, other spelling
308
+ ```
309
+
310
+ Which is the shape your schema was written against — and the shape whose errors
311
+ come back keyed the same way, because Standard Schema issue paths join with
312
+ dots too. A refused `address.city` is `errors['address.city']`.
313
+
314
+ Rows you add and remove are ordinary state; only the *names* have to line up:
315
+
316
+ ```tsx
317
+ {rows.map((row, i) => (
318
+ <input key={row.id} name={`items[${i}].name`} defaultValue={row.name} />
319
+ ))}
320
+ ```
321
+
322
+ ### Controlling one field
323
+
324
+ Most fields need nothing — the DOM holds the value and it is read back on
325
+ submit. Two cases need more: a control with no native element behind it, and a
326
+ value you want to show *as it is typed*.
327
+
328
+ `field(name)` is for both. Spread it, the same way you would spread
329
+ react-hook-form's `<Controller>` render props:
330
+
331
+ ```tsx
332
+ <Form action={reportBug} schema={formSchema} defaultValues={{ description: '' }}>
333
+ {({ field, pending, errors }) => (
334
+ <Field data-invalid={!!errors.description}>
335
+ <FieldLabel htmlFor="description">Description</FieldLabel>
336
+
337
+ <InputGroup>
338
+ <InputGroupTextarea id="description" {...field('description')} rows={6} />
339
+ <InputGroupAddon align="block-end">
340
+ <InputGroupText>{field('description').value.length}/100 characters</InputGroupText>
341
+ </InputGroupAddon>
342
+ </InputGroup>
343
+
344
+ <FieldError errors={errors.description?.map((message) => ({ message }))} />
345
+ </Field>
346
+ )}
347
+ </Form>
348
+ ```
349
+
350
+ It gives you `{ name, value, onChange, onBlur }` — the same four things
351
+ `<Controller>` does, for the same reason.
352
+
353
+ `onChange` takes either a DOM event or a bare value, so a native input and a
354
+ Radix `Select` both work without a wrapper. A bound field is still an ordinary
355
+ named input, so it arrives in `FormData` with everything else: there is one
356
+ source of truth, and nothing merges.
357
+
358
+ Mix freely. Bind the one field that needs a character count and leave the rest
359
+ alone.
360
+
361
+ ### How a field is doing
362
+
363
+ `fieldState(name)` is the other half — what is *known* about a field, as
364
+ opposed to what is spread onto it:
365
+
366
+ ```tsx
367
+ {({ field, fieldState }) => {
368
+ const title = fieldState('title')
369
+
370
+ return (
371
+ <Field data-invalid={title.invalid}>
372
+ <FieldLabel htmlFor="title">Bug title</FieldLabel>
373
+ <Input id="title" {...field('title')} aria-invalid={title.invalid} />
374
+ <FieldError errors={title.errors.map((message) => ({ message }))} />
375
+ </Field>
376
+ )
377
+ }}
378
+ ```
379
+
380
+ Two objects rather than one, which is react-hook-form's split and it is right
381
+ for a mechanical reason: `touched` and `invalid` are not DOM attributes, so a
382
+ single spreadable object would put them on the element and React would warn
383
+ about every one.
384
+
385
+ **A field is checked when it is left, not as it is typed.** An error that
386
+ appears while someone is halfway through an email address is a form arguing
387
+ with them; leaving the field is the moment they have finished saying what they
388
+ meant. `touched` is what separates "not filled in yet" from "filled in
389
+ wrongly".
390
+
391
+ It works on ordinary uncontrolled fields too — the form listens for `focusout`
392
+ rather than each field listening for `blur`, so `<Input name="title" />` is
393
+ covered without being bound to anything.
394
+
395
+ ### After a successful submit
396
+
397
+ ```tsx
398
+ {({ succeeded, recentlySucceeded }) => (
399
+ <Button type="submit">{recentlySucceeded ? 'Saved ✓' : 'Save'}</Button>
400
+ )}
401
+ ```
402
+
403
+ `recentlySucceeded` is the same thing for two seconds — the tick that appears
404
+ and fades. It is state rather than a timer in every form that wants one,
405
+ because the timer has to be cleared when the component goes away and that is
406
+ the part people forget.
407
+
408
+ ### Why not a `<Field>` component
409
+
410
+ TanStack Form and react-hook-form both hand you a field through a render prop —
411
+ `<form.Field name="title" children={…}>`, `<Controller render={…}>`. It looks
412
+ like the more capable design, and the reason they need it is worth being precise
413
+ about, because the three of us are not in the same position.
414
+
415
+ **TanStack Form is controlled-first.** Every value lives in form state, so
416
+ without per-field subscriptions one keystroke would re-render every field. The
417
+ render prop is what scopes that, and the verbosity is the price of it.
418
+
419
+ **react-hook-form is uncontrolled-first**, like this. Its `register` is refs, not
420
+ state, so typing re-renders nothing — and `<Controller>` is the opt-in for the
421
+ fields that cannot work that way. The render prop there is doing something
422
+ narrower: it scopes the re-render of a *controlled* field to that field alone.
423
+
424
+ So the architecture here is react-hook-form's. The difference is what the
425
+ controlled opt-in costs: `field()` is a function call rather than a render prop,
426
+ which keeps the markup flat and means a bound field re-renders this component
427
+ rather than only itself.
428
+
429
+ That is the right trade for the number of controlled fields a form usually has —
430
+ one or two, for a character count or a control with no native element.
431
+
432
+ When it is not, put the field in its own component and subscribe there:
433
+
434
+ ```tsx
435
+ import { useField } from '@rsc-kit/core/Form'
436
+
437
+ function Title() {
438
+ const { field, invalid, errors, ...bound } = useField('title')
439
+
440
+ return <Input {...bound} aria-invalid={invalid} />
441
+ }
442
+ ```
443
+
444
+ `useField` re-renders **that component and nothing else** — not the form, not
445
+ its siblings. Which is what `<Controller>` achieves with a render prop, except
446
+ that the component you were going to write anyway is the boundary.
447
+
448
+ So the scoping is there when a form is large enough to need it, and the flat
449
+ markup is there when it is not. What their design also gives is per-field meta,
450
+ and that needed no render prop either: it is `fieldState()`.
451
+
452
+ ### Reading the values from elsewhere
453
+
454
+ `useFormValues()` reads them from anywhere inside the form — a preview, a
455
+ summary, a count of what has changed:
456
+
457
+ ```tsx
458
+ function Preview() {
459
+ const { title } = useFormValues<{ title: string }>()
460
+
461
+ return <h2>{title || 'Untitled'}</h2>
462
+ }
463
+ ```
464
+
465
+ Only **bound** values are here. An uncontrolled input's value belongs to the
466
+ DOM, and nothing can know it changed without listening to it — bind a field with
467
+ `field()` or `useField` and it appears.
468
+
469
+ :::note[Inside the form, not outside it]
470
+ Both hooks read a context, so they work anywhere below `<Form>`. That is usually
471
+ enough, because the `<form>` element can wrap as much of the page as you like,
472
+ and a submit button outside it is `form="the-id"`.
473
+
474
+ Worth knowing what the alternatives do here, because it is not as different as
475
+ it looks. Reaching a form from another component is a context in all three:
476
+ react-hook-form has `<FormProvider>` and `useFormContext()`, and TanStack Form
477
+ has `createFormHookContexts()` with `useFormContext()` — which its own
478
+ documentation calls a bridge for integration constraints, to be avoided when
479
+ passing the form as a prop is possible. Ours needs no extra provider only
480
+ because `<Form>` already is one.
481
+
482
+ Where they are genuinely more flexible is *where the state is created*: their
483
+ `useForm()` is called by you, so it can be hoisted as far up as you like. For
484
+ the case where that matters — something that is not a descendant — create the
485
+ store yourself and hand it to the form:
486
+
487
+ ```tsx
488
+ function Page() {
489
+ const store = useFormStore<{ title: string }>({ title: '' })
490
+
491
+ return (
492
+ <>
493
+ <TopBar store={store} /> {/* not inside the form */}
494
+ <Form action={save} store={store}>…</Form>
495
+ </>
496
+ )
497
+ }
498
+
499
+ function TopBar({ store }) {
500
+ const { title } = useFormValues<{ title: string }>(store)
501
+
502
+ return <h1>{title || 'Untitled'}</h1>
503
+ }
504
+ ```
505
+
506
+ `useFormStore` is the values and nothing else — no submit, no errors, no
507
+ optimistic updates. Creating it does not subscribe to it, so the component
508
+ holding it does not re-render on every keystroke and take the whole subtree
509
+ with it.
510
+
511
+ `useField(name, store)` and `useFormValues(store)` take one explicitly;
512
+ without one they read the context, which is what almost every form wants.
513
+ :::
514
+
515
+ ## It works before hydration
516
+
517
+ The action goes on the `<form>` element as well as into the submit handler, so
518
+ the markup is submittable on its own. Someone who hits enter before the
519
+ javascript arrives still reaches the server; the page reloads with the result
520
+ instead of updating in place.
521
+
522
+ The two do not fight. The handler calls `preventDefault()` first, and React does
523
+ not run a form action for a submit that was cancelled — so the enhanced path
524
+ wins whenever there is one, and the native path is what is left when there is
525
+ not.
526
+
527
+ Nothing to turn on. It is why the fields are real `name` attributes rather than
528
+ controlled state: a browser can read them without help.
@@ -0,0 +1,83 @@
1
+ # Images
2
+
3
+ > Responsive images with no optimizer to run — unpic for a CDN, imagetools for files in the repo.
4
+
5
+ There is no image component and no image server. `next/image` is two things
6
+ glued together: a component that writes `srcset` and `sizes` for you, and an
7
+ optimizer that resizes on the fly — a process with sharp in it, a cache to
8
+ manage, and a CPU bill on every cold hit. The first half is worth having. The
9
+ second belongs to whatever already serves your images.
10
+
11
+ ## Images on a CDN
12
+
13
+ If the file lives on Cloudinary, imgix, Cloudflare Images, Bunny, Vercel,
14
+ Netlify or any of the [other providers unpic knows](https://unpic.pics/img/react/),
15
+ it already has a resizing url. [unpic](https://unpic.pics) writes the `srcset`
16
+ against it:
17
+
18
+ ```tsx title="src/app/page.tsx"
19
+ import { Image } from '@unpic/react';
20
+
21
+ <Image
22
+ src="https://res.cloudinary.com/demo/image/upload/sample.jpg"
23
+ layout="constrained"
24
+ width={800}
25
+ height={600}
26
+ alt="A sample"
27
+ />
28
+ ```
29
+
30
+ It is a plain component, so it renders in a server component and the browser
31
+ receives an `<img>` — `srcset` from 640w up, `sizes`, `aspect-ratio`,
32
+ `loading="lazy"`, `decoding="async"` — and none of unpic's code. The route's
33
+ javascript does not change. The CDN is detected from the url; nothing to
34
+ configure.
35
+
36
+ TanStack Start and Astro point at the same library, for the same reason.
37
+
38
+ ## Images in the repository
39
+
40
+ A file under `src/` is resized once, at build time, by
41
+ [vite-imagetools](https://github.com/JonasKruckenberg/imagetools). Query
42
+ parameters on the import say what you want:
43
+
44
+ ```ts title="vite.config.ts"
45
+ import { imagetools } from 'vite-imagetools';
46
+
47
+ export default defineConfig({
48
+ plugins: [imagetools(), rscKit(), ...],
49
+ });
50
+ ```
51
+
52
+ ```tsx title="src/app/page.tsx"
53
+ import hero from '../hero.png?w=400;800;1200&format=webp&as=srcset';
54
+ import heroSrc from '../hero.png?w=800&format=webp';
55
+
56
+ <img srcSet={hero} src={heroSrc} sizes="(min-width: 800px) 800px, 100vw" width={800} height={600} alt="…" />
57
+ ```
58
+
59
+ The build emits `hero-<hash>.webp` at each width into `assets/`, hashed and
60
+ cacheable forever, and a page that is frozen carries the urls. It costs the
61
+ build what resizing costs, once per image per width, and nothing at request
62
+ time. It is opt-in for that reason: a hundred hero images at four widths is a
63
+ noticeable build, and most of them belong on a CDN.
64
+
65
+ Declare the query so TypeScript stops asking:
66
+
67
+ ```ts title="src/images.d.ts"
68
+ declare module '*?*' {
69
+ const value: string;
70
+ export default value;
71
+ }
72
+ ```
73
+
74
+ ## What to pick
75
+
76
+ | the image is | use |
77
+ | --- | --- |
78
+ | user-uploaded, or on a CDN already | unpic |
79
+ | in the repo, a handful | imagetools |
80
+ | in the repo, hundreds | put them on a CDN and use unpic |
81
+ | an icon or a logo | `<img>`, or inline the svg |
82
+
83
+ Neither one runs at request time, and neither is on the page's javascript.