@preprio/toolkit 0.5.0-rc.2 → 0.6.0-alpha.1

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 (78) hide show
  1. package/README.md +415 -27
  2. package/dist/astro/chunk-O7N6GPAH.js +173 -0
  3. package/dist/astro/graphql.cjs +1030 -0
  4. package/dist/astro/graphql.d.cts +305 -0
  5. package/dist/astro/graphql.d.ts +305 -0
  6. package/dist/astro/graphql.js +936 -0
  7. package/dist/astro/index-XT_z-RYT.d.cts +130 -0
  8. package/dist/astro/index-XT_z-RYT.d.ts +130 -0
  9. package/dist/astro/index.cjs +14 -1
  10. package/dist/astro/index.d.cts +1 -120
  11. package/dist/astro/index.d.ts +1 -120
  12. package/dist/astro/index.js +15 -153
  13. package/dist/chunk-3JLWZQ37.js +179 -0
  14. package/dist/chunk-3NCEVJJ3.js +10 -0
  15. package/dist/chunk-S5RWVMDJ.js +148 -0
  16. package/dist/chunk-TBKSBXGH.js +452 -0
  17. package/dist/{chunk-FLPNN3CV.js → chunk-VI6QZTBM.js} +73 -276
  18. package/dist/chunk-YWIKKWBR.js +26 -0
  19. package/dist/graphql/codegen.cjs +510 -0
  20. package/dist/graphql/codegen.d.cts +23 -0
  21. package/dist/graphql/codegen.d.ts +23 -0
  22. package/dist/graphql/codegen.js +99 -0
  23. package/dist/graphql.cjs +941 -0
  24. package/dist/graphql.d.cts +305 -0
  25. package/dist/graphql.d.ts +305 -0
  26. package/dist/graphql.js +419 -0
  27. package/dist/index.cjs +76 -119
  28. package/dist/index.d.cts +12 -64
  29. package/dist/index.d.ts +12 -64
  30. package/dist/index.js +27 -167
  31. package/dist/nextjs/chunk-BQ6IGSH6.js +231 -0
  32. package/dist/nextjs/components.cjs +190 -122
  33. package/dist/nextjs/components.d.cts +33 -2
  34. package/dist/nextjs/components.d.ts +33 -2
  35. package/dist/nextjs/components.js +133 -4
  36. package/dist/nextjs/graphql.cjs +1362 -0
  37. package/dist/nextjs/graphql.d.cts +435 -0
  38. package/dist/nextjs/graphql.d.ts +435 -0
  39. package/dist/nextjs/graphql.js +1261 -0
  40. package/dist/nextjs/index.cjs +126 -6
  41. package/dist/nextjs/index.d.cts +53 -147
  42. package/dist/nextjs/index.d.ts +53 -147
  43. package/dist/nextjs/index.js +113 -203
  44. package/dist/nextjs/server-CjpELYvq.d.cts +156 -0
  45. package/dist/nextjs/server-CjpELYvq.d.ts +156 -0
  46. package/dist/nuxt/chunk-IQDSBPDN.js +417 -0
  47. package/dist/nuxt/graphql.cjs +1037 -0
  48. package/dist/nuxt/graphql.d.cts +302 -0
  49. package/dist/nuxt/graphql.d.ts +302 -0
  50. package/dist/nuxt/graphql.js +937 -0
  51. package/dist/nuxt/index.cjs +14 -1
  52. package/dist/nuxt/index.d.cts +3 -148
  53. package/dist/nuxt/index.d.ts +3 -148
  54. package/dist/nuxt/index.js +15 -391
  55. package/dist/nuxt/server-8v6OHbsw.d.cts +158 -0
  56. package/dist/nuxt/server-8v6OHbsw.d.ts +158 -0
  57. package/dist/pixel-DwW7cXJm.d.cts +49 -0
  58. package/dist/pixel-DwW7cXJm.d.ts +49 -0
  59. package/dist/react/components.cjs +88 -120
  60. package/dist/react/components.d.cts +2 -8
  61. package/dist/react/components.d.ts +2 -8
  62. package/dist/react/components.js +88 -120
  63. package/dist/server-Brr1UgtW.d.ts +59 -0
  64. package/dist/server-DJuS3YU0.d.cts +59 -0
  65. package/dist/sveltekit/chunk-OKYZ6HPH.js +413 -0
  66. package/dist/sveltekit/graphql.cjs +1043 -0
  67. package/dist/sveltekit/graphql.d.cts +307 -0
  68. package/dist/sveltekit/graphql.d.ts +307 -0
  69. package/dist/sveltekit/graphql.js +943 -0
  70. package/dist/sveltekit/index.cjs +14 -1
  71. package/dist/sveltekit/index.d.cts +3 -88
  72. package/dist/sveltekit/index.d.ts +3 -88
  73. package/dist/sveltekit/index.js +15 -387
  74. package/dist/sveltekit/server-CO847mEV.d.cts +98 -0
  75. package/dist/sveltekit/server-CO847mEV.d.ts +98 -0
  76. package/dist/{pixel-B3oQNbZ3.d.cts → types-BYH9XqmN.d.cts} +4 -58
  77. package/dist/{pixel-B3oQNbZ3.d.ts → types-BYH9XqmN.d.ts} +4 -58
  78. package/package.json +36 -1
package/README.md CHANGED
@@ -200,25 +200,326 @@ To skip Prepr for some routes entirely, return early with `NextResponse.next()`
200
200
 
201
201
  The pixel collects the interaction data that powers personalization and A/B testing. Include it in **all** environments, preview and production alike — the Quick Start layout already does. `PreprTrackingPixel` renders nothing and loads the CDN script on mount, so its position in the tree does not matter.
202
202
 
203
- ### With Apollo Client
203
+ ### Fetching content
204
+
205
+ `createPreprClient` sends codegen-typed queries to Prepr and returns Prepr's personalization verdict next to the data. It lives on its own subpath and needs the `graphql` package, which graphql-codegen already installs (otherwise: `npm install graphql`).
204
206
 
205
207
  ```typescript
206
- import { getClient } from '@/lib/client';
208
+ import { createPreprClient } from '@preprio/toolkit/nextjs/graphql';
207
209
  import { GetPageBySlugDocument } from '@/gql/graphql';
208
- import { getPreprHeaders } from '@preprio/toolkit/nextjs';
209
210
 
210
- async function getPage(slug: string) {
211
- const { data } = await getClient().query({
212
- query: GetPageBySlugDocument,
213
- variables: { slug },
214
- context: { headers: await getPreprHeaders() },
215
- fetchPolicy: 'no-cache',
216
- });
211
+ const prepr = createPreprClient({ url: process.env.PREPR_GRAPHQL_URL! });
212
+
213
+ // Same response for every visitor: goes through the Data Cache, tagged 'prepr'.
214
+ const { data } = await prepr.query(GetPageBySlugDocument, { slug });
215
+
216
+ // This visitor's segment / A-B variant: forwards the Prepr headers, never cached.
217
+ const { data, personalized } = await prepr.query(
218
+ GetPageBySlugDocument,
219
+ { slug },
220
+ { personalize: true },
221
+ );
222
+ ```
223
+
224
+ Codegen's default client-preset output works as is; `documentMode: 'string'` works too.
225
+
226
+ `personalized` is `true` when the queried fields contain segment variants or A/B tests, at any depth. Use it to decide whether a page can be served statically.
227
+
228
+ To serve pages from the cache until you publish, set `revalidate: false` and call `revalidateTag('prepr')` from a Prepr publish webhook. With `revalidate` unset, the installed Next version's fetch caching default applies.
229
+
230
+ In preview, pass `preview: true`: queries skip the cache and stega markers stay in text for click-to-edit. They are always stripped from URL-like fields.
231
+
232
+ Custom headers go in two places. Client-level `headers` (`createPreprClient({ url, headers })`) are sent with every request and keep queries cacheable, so use them for fixed values such as a header your proxy requires. Per-query `headers` (or `init.headers`) may carry per-visitor values, so they make that request `no-store`.
233
+
234
+ #### Personalization with Cache Components
235
+
236
+ With [Cache Components](https://nextjs.org/docs/app/getting-started/cache-components) (`cacheComponents: true`), a personalized page is still served from the cache: what never varies and every A/B test are in the static shell, and only the parts that differ per segment stream in. You keep your own query and your own components.
237
+
238
+ **Do you need this?** Only if your pages are cached. A page that renders per request already gets this visitor's variants from `query(..., { personalize: true })`; render the result as is and skip this section.
239
+
240
+ A normal page needs three changes:
241
+
242
+ ```tsx
243
+ import { PreprStack } from '@preprio/toolkit/nextjs/graphql';
217
244
 
245
+ // 1. Fetch every variant, in a cached function. Same query, one extra option.
246
+ async function getPage(slug: string) {
247
+ 'use cache';
248
+ const { data } = await prepr.query(
249
+ PageDocument,
250
+ { slug },
251
+ { variants: true },
252
+ );
218
253
  return data;
219
254
  }
255
+
256
+ export default async function Page({
257
+ params,
258
+ }: {
259
+ params: Promise<{ slug?: string[] }>;
260
+ }) {
261
+ const slug = (await params).slug?.join('/') || '/';
262
+ const data = await getPage(slug);
263
+
264
+ // 2. Ask which variants this visitor gets. Do not await it.
265
+ const decisions = prepr.decide(PageDocument, { slug }, { from: data });
266
+
267
+ // 3. Render the list through PreprStack instead of directly.
268
+ return (
269
+ <PreprStack
270
+ items={data.Page.content}
271
+ decisions={decisions}
272
+ render={(items, { index }) => <Sections items={items} offset={index} />}
273
+ />
274
+ );
275
+ }
276
+ ```
277
+
278
+ ##### Your components: one extra prop
279
+
280
+ `PreprStack` splits the list into blocks (what never varies, each personalization group, each A/B test) and calls `render` once per block. Each call receives items exactly as a query without personalization would return them: the variant this visitor gets, with every list nested inside an item narrowed down the same way.
281
+
282
+ Because `render` runs per block, a position counted inside `Sections` (`items.map((item, i) => …)`) starts at 0 in every block. `index` is where the block starts in the whole page; add it, and the first section still owns the `<h1>` and the priority image:
283
+
284
+ ```tsx
285
+ // Before
286
+ <Sections items={data.Page.content} />
287
+
288
+ // After
289
+ <PreprStack
290
+ items={data.Page.content}
291
+ decisions={decisions}
292
+ render={(items, { index }) => <Sections items={items} offset={index} />}
293
+ />
294
+
295
+ // Sections: the only change is `offset + i`
296
+ function Sections({ items, offset = 0 }: { items: Section[]; offset?: number }) {
297
+ return items.map((item, i) => <Section key={item._id} item={item} position={offset + i} />);
298
+ }
299
+ ```
300
+
301
+ A group takes one position, whichever variant shows. A group that shows nothing to a visitor (only a segment variant, no default) still takes its position: the page is prerendered before anyone's segment is known.
302
+
303
+ **The one rule:** a `variants: true` result holds every variant. Every list you render from it goes through `PreprStack`, or visitors see all variants at once.
304
+
305
+ ##### Fitting your page
306
+
307
+ | Your page has… | Do this |
308
+ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
309
+ | One page-builder list (`content`, `sections`, `stack`) | One `PreprStack` around it. |
310
+ | Personalized lists inside sections (cards, buttons, models in a section) | Nothing. They are narrowed down with the section they sit in. |
311
+ | Several lists (hero, body, sidebar) | One `PreprStack` per list, all with the same `decisions`. |
312
+ | A personalized list outside any list you already wrap (e.g. `Page.buttons`) | Its own `PreprStack`. |
313
+ | Data used outside rendering (`generateMetadata`, JSON-LD) | `resolveVariants(data)` gives the default variants and A. |
314
+ | Impression tracking for Prepr's metrics | `trackVariants` on `PreprStack`; see [Impressions and conversions](#impressions-and-conversions). |
315
+ | No personalization on a page | Nothing to change. `PreprStack` renders it statically and `decide()` sends no request. |
316
+
317
+ ##### Impressions and conversions
318
+
319
+ Prepr's tracking pixel counts impressions of an element carrying `data-prepr-variant-key`, and clicks on any `data-prepr-variant-event` element inside it ([Prepr docs](https://docs.prepr.io/personalization/setting-up-personalization#track-impressions-and-conversions)). The client already fetches every variant's key, so you never select `_context { variant_key }` yourself.
320
+
321
+ Opt in with `trackVariants`, and `PreprStack` puts the key on the section each personalized and A/B variant renders:
322
+
323
+ ```tsx
324
+ <PreprStack items={data.Page.content} decisions={decisions} trackVariants render={…} />
325
+ ```
326
+
327
+ For variants nested inside a section (cards, buttons, models), and outside Next.js, spread `preprVariantProps(item)` on the element that shows the item:
328
+
329
+ ```tsx
330
+ <article {...preprVariantProps(card)}>…</article>
331
+ ```
332
+
333
+ Conversions stay your choice: put `data-prepr-variant-event` (or `data-prepr-variant-event="QuoteRequest"`) on the element that counts, for example once in your button component.
334
+
335
+ - Keys are added after the page hydrates; the pixel picks them up then. Each item of a variant tags the element it renders, in order. A variant that renders a different number of elements than it has items (a section with several root elements, or one rendering nothing) tags only its first element.
336
+ - With `trackVariants`, each variant sits in a `display: contents` wrapper. It takes no box, but CSS child combinators (`>`) and `:first-child` from the stack's parent no longer reach the sections.
337
+ - An element that already has `data-prepr-variant-key` keeps it, so existing manual tagging keeps working.
338
+ - Tagging yourself (with your own `_context { variant_key }` or `preprVariantProps`)? Skip the tag when `render` gets `placeholder: true`: that render is the default variant standing in until the visitor's variant of a segment group streams in, and the pixel counts anything with a key that appears on screen, however briefly.
339
+
340
+ ```tsx
341
+ render={(items, { index, placeholder }) => (
342
+ <Sections items={items} offset={index} tagVariants={!placeholder} />
343
+ )}
344
+ ```
345
+
346
+ - A hidden A/B variant never scrolls into view, so it records no impression.
347
+
348
+ ##### A/B tests
349
+
350
+ `PreprStack` renders both variants of an A/B test in the static shell and shows the visitor's one before first paint; A is the default, also for crawlers. The visitor's bucket comes from a cookie set in your middleware. Prepr assigns the bucket, once per visitor, and only on pages that have an A/B test:
351
+
352
+ ```ts
353
+ // proxy.ts
354
+ const response = createPreprMiddleware(request, { preview });
355
+ await resolvePreprBucket(request, response, {
356
+ url: process.env.PREPR_GRAPHQL_URL!,
357
+ preview,
358
+ onlyIf: () => prepr.hasAbTests(PageDocument, { slug }),
359
+ });
360
+ return response;
361
+ ```
362
+
363
+ Sites without A/B tests leave this out. Under a strict Content-Security-Policy, pass `nonce` to `PreprStack`.
364
+
365
+ ##### Personalized content above the fold
366
+
367
+ A segment group streams in: the default variant shows first, then this visitor's variant replaces it. Below the fold nobody sees that. In the hero they do, so for the blocks on screen before the visitor scrolls, decide in the proxy instead:
368
+
369
+ ```tsx
370
+ <PreprStack items={data.Page.content} decisions={decisions} aboveTheFold={1} render={…} />
371
+ ```
372
+
373
+ ```ts
374
+ // proxy.ts, after createPreprMiddleware
375
+ await prepr.resolveDecisions(
376
+ request,
377
+ response,
378
+ PageDocument,
379
+ { slug },
380
+ {
381
+ // Only pages whose personalized content is above the fold need it.
382
+ onlyIf: () => slug === '/',
383
+ },
384
+ );
385
+ ```
386
+
387
+ `PreprStack` puts every variant of those blocks' segment groups in the cached page, and the A/B switch script shows the variant the proxy decided before first paint: nothing on screen changes. `resolveDecisions` forwards the visitor's headers (customer id, UTM parameters, IP, HubSpot id) to Prepr and stores the answer in the `prepr_decisions` cookie.
388
+
389
+ - It asks Prepr once per visit, not per page view: later requests reuse the stored decision unless something arrives that can change the segment (UTM parameters, a first visit, a segment forced in preview). Pass `reuse: false` to ask on every request. A visitor who joins a segment mid-visit sees it from the next request that asks.
390
+ - That request is uncached, so its time depends on how far your functions run from Prepr: deploy them in the region closest to your visitors and to Prepr, and use `onlyIf` to limit it to pages that need it. Pages without segment groups are skipped after a cached check (the same one `hasAbTests` uses).
391
+ - Without a decision (the lookup failed or took longer than `timeoutMs`, default 1000), the default variant shows.
392
+ - The page carries the HTML of every variant of those groups, and images in hidden variants still download.
393
+ - An item with a segment group nested inside it keeps streaming; only groups at the top level of the list are decided in the proxy.
394
+
395
+ ##### Preview
396
+
397
+ Run preview in [draft mode](https://nextjs.org/docs/app/guides/draft-mode), so editors see an edit on the next load instead of the cached page:
398
+
399
+ ```ts
400
+ // app/api/prepr/draft/route.ts
401
+ export const GET = createPreprDraftRoute({ preview });
402
+
403
+ // proxy.ts, before anything else
404
+ const draft = preprDraftRedirect(request, { preview });
405
+ if (draft) return draft;
406
+ ```
407
+
408
+ Outside preview both do nothing (the route answers 404). Keep the route out of your proxy `matcher`, or pass its path as `route` when it lives elsewhere. Inside the Prepr editor, browsers that refuse the draft cookie get the cached page instead, so keep a publish webhook calling `revalidateTag('prepr')`.
409
+
410
+ <details>
411
+ <summary>How it works</summary>
412
+
413
+ - **`variants: true`** adds `personalize: false` to every personalizable field in your query, at any depth, and selects each item's `_context`. The client learns which fields are personalizable from one schema introspection request per client, kept in the Data Cache.
414
+ - **`decide()`** sends your query cut down to the lists that hold segment groups on this page (`from: data`), selecting only `_context`, with the visitor's headers and never cached. Prepr decides from everything the middleware forwards: customer profile, UTM parameters, visitor IP, HubSpot id. A page without segment groups makes no request.
415
+ - **`PreprStack`** renders items that never vary in the static shell. Each segment group, and each item with a segment group nested inside it, gets its own `<Suspense>` boundary: the default variant is the fallback and this visitor's variant streams in. A group with no default variant still gets its place. A failed decision falls back to the defaults.
416
+ - **A/B variants** are both in the HTML, each in a `display: contents` wrapper; an inline script (and a layout effect after in-app navigation) shows the bucket from the cookie. A hidden variant never becomes visible, so the tracking pixel records no impression for it.
417
+ - **`hasAbTests`** sends your query without its leaf fields and no visitor headers, so Prepr's CDN caches it, and reads the answer from the `Vary` header. Answers are reused for a minute.
418
+ - **Tags carry over.** The client's `'prepr'` fetch tag applies to the `'use cache'` entry, so `revalidateTag('prepr')` refreshes it without a `cacheTag()` call.
419
+ - **Outside `'use cache'`, set `revalidate`** on the client (`false` or seconds). Otherwise a query outside both `'use cache'` and `<Suspense>` fails the build with "uncached data was accessed outside of `<Suspense>`".
420
+
421
+ </details>
422
+
423
+ `examples/nextjs-cache-components` is a runnable version.
424
+
425
+ Do not catch and swallow errors from `query` without [`unstable_rethrow`](https://nextjs.org/docs/app/api-reference/functions/unstable_rethrow): Next.js signals dynamic rendering by throwing from inside `fetch`, and the client passes those signals through.
426
+
427
+ #### Using your own GraphQL client
428
+
429
+ Everything the toolkit's client does for personalization is two query rewrites and one helper. Use them with any client: Apollo, urql, or plain `fetch`.
430
+
431
+ 1. **Variants query** (same for every visitor, cache it): `toVariantsQuery(query, schema)`.
432
+ 2. **Decision query** (per visitor, never cached, with the visitor's Prepr headers): `toDecisionQuery(query, schema, { from: variantsData })`, then `decisionsFrom(data)`.
433
+ 3. **Render** with `PreprStack` (Next.js) or `resolveVariants(data, { decisions, bucket })` anywhere.
434
+
435
+ The rewrites need to know which fields take `personalize`: `loadPreprSchema(url)` fetches that once and keeps it in memory.
436
+
437
+ ```ts
438
+ import {
439
+ decisionsFrom,
440
+ loadPreprSchema,
441
+ resolveVariants,
442
+ toDecisionQuery,
443
+ toVariantsQuery,
444
+ } from '@preprio/toolkit/graphql';
445
+
446
+ const url = process.env.PREPR_GRAPHQL_URL!;
447
+ const schema = await loadPreprSchema(url);
448
+
449
+ async function post(
450
+ query: string,
451
+ variables: object,
452
+ headers: HeadersInit = {},
453
+ ) {
454
+ const res = await fetch(url, {
455
+ method: 'POST',
456
+ headers: { 'Content-Type': 'application/json', ...headers },
457
+ body: JSON.stringify({ query, variables }),
458
+ });
459
+ const json = await res.json();
460
+ if (!res.ok || json.errors)
461
+ throw new Error(`Prepr GraphQL request failed (${res.status})`);
462
+ return json.data;
463
+ }
464
+
465
+ const variables = { slug: '/' };
466
+ const page = await post(toVariantsQuery(PAGE_QUERY, schema), variables); // cacheable
467
+
468
+ const decisionQuery = toDecisionQuery(PAGE_QUERY, schema, { from: page });
469
+ const decisions = decisionQuery
470
+ ? decisionsFrom(await post(decisionQuery, variables, visitorPreprHeaders))
471
+ : new Map();
472
+
473
+ const content = resolveVariants(page.Page.content, { decisions, bucket });
474
+ ```
475
+
476
+ `visitorPreprHeaders` are the headers the middleware forwards (`getPreprHeaders()` in Next.js); `bucket` is the visitor's A/B bucket (`resolvePreprBucket` stores it in a cookie).
477
+
478
+ With Apollo, parse the strings once and use them like any document:
479
+
480
+ ```ts
481
+ import { gql } from '@apollo/client';
482
+
483
+ const PageVariants = gql(toVariantsQuery(PAGE_QUERY, schema));
484
+ const { data: page } = await client.query({ query: PageVariants, variables });
485
+
486
+ const decisionQuery = toDecisionQuery(PAGE_QUERY, schema, { from: page });
487
+ const decisions = decisionQuery
488
+ ? decisionsFrom(
489
+ (
490
+ await client.query({
491
+ query: gql(decisionQuery),
492
+ variables,
493
+ context: { headers: visitorPreprHeaders },
494
+ fetchPolicy: 'no-cache', // per visitor: never from Apollo's cache
495
+ })
496
+ ).data,
497
+ )
498
+ : new Map();
220
499
  ```
221
500
 
501
+ **At build time with graphql-codegen.** The plugin writes both queries per named query into a file, so nothing is rewritten and no schema is loaded at runtime:
502
+
503
+ ```ts
504
+ // codegen.ts
505
+ generates: {
506
+ 'src/gql/prepr-queries.ts': {
507
+ plugins: ['@preprio/toolkit/graphql/codegen'],
508
+ },
509
+ },
510
+ ```
511
+
512
+ ```ts
513
+ import {
514
+ GetPageBySlugDecisionQuery,
515
+ GetPageBySlugVariantsQuery,
516
+ } from './gql/prepr-queries';
517
+ ```
518
+
519
+ Only named queries with personalizable fields get exports; two queries with the same name fail the run. The generated decision query asks about every personalizable list. When you have the variants result at hand, `toDecisionQuery(query, schema, { from })` is smaller and markedly faster for visitors Prepr has not seen before.
520
+
521
+ A/B tests need no decision query: render both variants and pick the visitor's bucket, as `PreprStack` and `resolvePreprBucket` do, or pass the bucket to `resolveVariants`.
522
+
222
523
  ### Image loader
223
524
 
224
525
  Prepr's stream CDN resizes at the edge. Routing `next/image` through the bundled loader makes Next request the exact width it needs, instead of pulling the full-resolution original and re-encoding it through `/_next/image`.
@@ -295,6 +596,19 @@ import { getPreprHeaders } from '@preprio/toolkit/astro';
295
596
  const headers = getPreprHeaders(Astro.request.headers);
296
597
  ```
297
598
 
599
+ Or let the client forward the headers (needs `graphql`, see [Fetching content](#fetching-content)):
600
+
601
+ ```typescript
602
+ import { createPreprClient } from '@preprio/toolkit/astro/graphql';
603
+
604
+ const prepr = createPreprClient({ url: import.meta.env.PREPR_GRAPHQL_URL });
605
+ const { data } = await prepr.query(
606
+ GetPageBySlugDocument,
607
+ { slug },
608
+ { personalize: true, event: Astro },
609
+ );
610
+ ```
611
+
298
612
  `import.meta.env.DEV` is Astro's own dev flag; swap in whatever signal marks preview in your deployment.
299
613
 
300
614
  The `.astro` components ship as source, compiled by your own Astro/Vite pipeline. They use full-page navigation by default, matching Astro's MPA model — switching segment or variant re-runs the server-rendered output.
@@ -357,6 +671,23 @@ import { getPreprHeadersFromLocals } from '@preprio/toolkit/sveltekit';
357
671
  const preprHeaders = getPreprHeadersFromLocals(event.locals);
358
672
  ```
359
673
 
674
+ Or let the client forward them (needs `graphql`, see [Fetching content](#fetching-content)). Passing the event also routes the request through `event.fetch`:
675
+
676
+ ```typescript
677
+ import { createPreprClient } from '@preprio/toolkit/sveltekit/graphql';
678
+
679
+ const prepr = createPreprClient({ url: env.PREPR_GRAPHQL_URL });
680
+
681
+ export const load: PageServerLoad = async (event) => {
682
+ const { data } = await prepr.query(
683
+ GetPageBySlugDocument,
684
+ { slug: event.params.slug },
685
+ { personalize: true, event },
686
+ );
687
+ return { page: data.Page };
688
+ };
689
+ ```
690
+
360
691
  The `.svelte` components ship as source (compiled by your own Vite/Svelte pipeline) and mount client-side via `onMount`, so they never run during SSR.
361
692
 
362
693
  See the runnable example at `examples/sveltekit` for the full source of truth.
@@ -408,6 +739,26 @@ import { getPreprHeadersFromEvent } from '@preprio/toolkit/nuxt';
408
739
  const preprHeaders = getPreprHeadersFromEvent(event);
409
740
  ```
410
741
 
742
+ Or let the client forward them from a server route (needs `graphql`, see [Fetching content](#fetching-content)):
743
+
744
+ ```typescript
745
+ // server/api/page.get.ts
746
+ import { createPreprClient } from '@preprio/toolkit/nuxt/graphql';
747
+
748
+ const prepr = createPreprClient({ url: useRuntimeConfig().preprGraphqlUrl });
749
+
750
+ export default defineEventHandler(async (event) => {
751
+ const { data } = await prepr.query(
752
+ GetPageBySlugDocument,
753
+ { slug: getQuery(event).slug },
754
+ { personalize: true, event },
755
+ );
756
+ return data;
757
+ });
758
+ ```
759
+
760
+ `personalize: true` needs the h3 event. During client-side navigation there is none and the query throws `CONTEXT_ERROR`, so run personalized queries in server routes.
761
+
411
762
  The `.vue` components ship as source (compiled by your own Vite/Vue pipeline) and mount client-side via `onMounted`, so they never run during SSR.
412
763
 
413
764
  See the runnable example at `examples/nuxt` for the full source of truth.
@@ -549,6 +900,38 @@ Each wrapper exposes the same five helpers, differing only in how they reach the
549
900
 
550
901
  `getPreprUUID()` returning `null` is a useful check when headers seem to be missing — it means the middleware did not cover the route.
551
902
 
903
+ ### GraphQL client
904
+
905
+ `createPreprClient(options)` ships on `@preprio/toolkit/graphql` and on each framework's `/graphql` subpath (`@preprio/toolkit/nextjs/graphql`, `/nuxt/graphql`, `/sveltekit/graphql`, `/astro/graphql`). These subpaths need the `graphql` package (`>=16`); nothing else in the toolkit does. The core version takes explicit `headers`; the framework versions add `personalize`.
906
+
907
+ | Option | Default | Effect |
908
+ | ---------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
909
+ | `url` | — | Full `https://graphql.prepr.io/<token>` URL. Throws `PreprError` when invalid. |
910
+ | `preview` | `false` | Every query is `no-store`; stega defaults to `'urls'`. |
911
+ | `stega` | `'urls'` in preview, else `'all'` | Which strings get stega markers stripped: URL-like keys only, all, or `'none'`. |
912
+ | `fetch` | global `fetch` | Custom fetch. Also accepted per query. |
913
+ | `fastlyQuery` | `false` | Send queries as a GET with a `Fastly-Query` header, served by Prepr's edge. Opt-in: undocumented Prepr behavior. |
914
+ | `headers` | — | Sent with every request. Fixed per client, so queries stay cacheable. |
915
+ | `cacheTags` (Next.js) | `['prepr']` | Tags on cacheable fetches. |
916
+ | `revalidate` (Next.js) | unset | `next.revalidate` on cacheable fetches. |
917
+
918
+ `query(document, variables?, options?)` takes a query string, a `TypedDocumentString` or a `DocumentNode`, and resolves to `{ data, personalized }`. Per-query options: `headers`, `init`, `fetch`, plus `personalize` (framework versions) and `event` (Nuxt, SvelteKit, Astro). A request carrying per-query headers or `init.headers` is `no-store`; client-level `headers` are not.
919
+
920
+ Import `PreprError` from the same subpath as the client when checking `instanceof`: CommonJS entry points do not share classes. `error.code` works from anywhere.
921
+
922
+ `isPersonalizedResponse(response)`, exported from every entry point, reads the same verdict from a response fetched with your own client. It does not need `graphql`.
923
+
924
+ For your own GraphQL client (see [Using your own GraphQL client](#using-your-own-graphql-client)), every `/graphql` subpath also exports:
925
+
926
+ | Function | Returns |
927
+ | ---------------------------------------------------------- | -------------------------------------------------------------------------------------- |
928
+ | `loadPreprSchema(url, { fetch? })` | What the rewrites need from your schema, loaded once per URL. Failures are not cached. |
929
+ | `toVariantsQuery(query, schema)` | The variants query as a string: what `variants: true` sends. |
930
+ | `toDecisionQuery(query, schema, { from?, topLevelOnly? })` | The per-visitor decision query as a string, or `null` when there is nothing to decide. |
931
+ | `decisionsFrom(data)` | Decisions from a decision query's `data`, for `resolveVariants` or `PreprStack`. |
932
+
933
+ `@preprio/toolkit/graphql/codegen` is a graphql-codegen plugin that writes `<Name>VariantsQuery` and `<Name>DecisionQuery` for every named query with personalizable fields.
934
+
552
935
  ### Token helpers
553
936
 
554
937
  Exported from every entry point, including the core.
@@ -694,14 +1077,14 @@ createPreprMiddleware(request, response, { preview });
694
1077
  createPreprPreview({ props, options: { debug: true, locale: 'nl' } });
695
1078
  ```
696
1079
 
697
- | Option | Type | Default | Description |
698
- | ---------------------- | --------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
699
- | `debug` | `boolean` | `false` | Enable debug logging. |
700
- | `locale` | `'en' \| 'nl'` | auto-detected | UI language. Falls back to the first supported browser language, then `en`. |
701
- | `features` | `PreprFeatures` | all enabled | Which features run. See [Feature flags](#feature-flags). |
702
- | `ui` | `boolean` | `true` | Mount the visible toolbar. See [Headless preview](#headless-preview-no-toolbar-ui). |
703
- | `allowedEditorOrigins` | `string[]` | `*.prepr.io` | Exact editor origins allowed to drive this preview, for self-hosted or local editors. Replaces the wildcard. `['*']` accepts any origin — development only. |
704
- | `autoClean` | `boolean` | `true` | Strip the invisible stega characters from visible text. See [Stega auto-cleaning](#stega-auto-cleaning). |
1080
+ | Option | Type | Default | Description |
1081
+ | ---------------------- | --------------- | ------------- | -------------------------------------------------------------------------------------------------------- |
1082
+ | `debug` | `boolean` | `false` | Enable debug logging. |
1083
+ | `locale` | `'en' \| 'nl'` | auto-detected | UI language. Falls back to the first supported browser language, then `en`. |
1084
+ | `features` | `PreprFeatures` | all enabled | Which features run. See [Feature flags](#feature-flags). |
1085
+ | `ui` | `boolean` | `true` | Mount the visible toolbar. See [Headless preview](#headless-preview-no-toolbar-ui). |
1086
+ | `allowedEditorOrigins` | `string[]` | `*.prepr.io` | Exact editor origins allowed to drive this preview, for self-hosted editors. Replaces the wildcard. |
1087
+ | `autoClean` | `boolean` | `true` | Strip the invisible stega characters from visible text. See [Stega auto-cleaning](#stega-auto-cleaning). |
705
1088
 
706
1089
  `features` and `ui` are independent: `features` decides _what runs_, `ui` decides _whether the toolbar is visible_.
707
1090
 
@@ -831,6 +1214,10 @@ createPreprPreview({ props, options: { locale: 'nl' } });
831
1214
  - **Check whether the encoded text sits in a hidden element.** The parent of the encoded text node is what becomes editable, and a parent that renders no box (`display: none`, `visibility: hidden`, the `hidden` attribute) is skipped — it cannot be hovered or clicked. Put `data-prepr-edit-target` on the visible ancestor that should be editable instead. See [Choosing the element that becomes editable](#choosing-the-element-that-becomes-editable).
832
1215
  - **Inspect the tagged elements**: `document.querySelectorAll('[data-prepr-encoded]')` in the console lists everything edit mode found. An element you expected to be in that list points at one of the two cases above.
833
1216
 
1217
+ ### Every variant shows at once
1218
+
1219
+ The list came from a `variants: true` query but was rendered directly. Pass it through `PreprStack` (or `resolveVariants`); see [Personalization with Cache Components](#personalization-with-cache-components).
1220
+
834
1221
  ### Headers not working
835
1222
 
836
1223
  - **Middleware setup**: confirm `middleware.ts` sits in the project root (or `src/`) and its matcher covers the route.
@@ -839,7 +1226,7 @@ createPreprPreview({ props, options: { locale: 'nl' } });
839
1226
 
840
1227
  ### Segments switch but content does not change
841
1228
 
842
- The toolbar sets the segment; your data fetch decides what to do with it. Check that the fetch forwards `getPreprHeaders()` and is not being served from cache — use `cache: 'no-store'`, or Apollo's `fetchPolicy: 'no-cache'`, on personalized queries.
1229
+ The toolbar sets the segment; your data fetch decides what to do with it. Check that the fetch forwards `getPreprHeaders()` and is not being served from cache — use `cache: 'no-store'`, or Apollo's `fetchPolicy: 'no-cache'`, on personalized queries. `createPreprClient` with `personalize: true` does both.
843
1230
 
844
1231
  ### Build issues
845
1232
 
@@ -865,7 +1252,7 @@ try {
865
1252
  }
866
1253
  ```
867
1254
 
868
- Codes: `INVALID_TOKEN`, `MISSING_TOKEN`, `HTTP_ERROR`, `FETCH_ERROR`, `INVALID_RESPONSE`, `CONTEXT_ERROR`.
1255
+ Codes: `INVALID_TOKEN`, `MISSING_TOKEN`, `HTTP_ERROR`, `FETCH_ERROR`, `INVALID_RESPONSE`, `CONTEXT_ERROR`, `GRAPHQL_ERROR`, `INVALID_DOCUMENT`.
869
1256
 
870
1257
  `getToolbarProps` is the exception — it swallows these and returns an empty segment list, so a bad token cannot crash your app.
871
1258
 
@@ -963,13 +1350,14 @@ Without it the `<em>` would be the editable element; with it the whole figure is
963
1350
 
964
1351
  Runnable examples live in the repository root:
965
1352
 
966
- | Example | What it shows |
967
- | -------------------- | ------------------------------------------------------------------ |
968
- | `examples/nextjs` | App Router, middleware, Apollo, custom image loader |
969
- | `examples/astro` | Astro middleware and `.astro` components |
970
- | `examples/sveltekit` | `hooks.server.ts`, `+layout.server.ts`, `.svelte` components |
971
- | `examples/nuxt` | `server/middleware`, `useAsyncData`, `.vue` components |
972
- | `examples/express` | Vanilla core — a hand-written adapter for an unsupported framework |
1353
+ | Example | What it shows |
1354
+ | ---------------------------------- | ------------------------------------------------------------------ |
1355
+ | `examples/nextjs` | App Router, middleware, `createPreprClient`, custom image loader |
1356
+ | `examples/nextjs-cache-components` | Cache Components: cached page shell, personalized `<Suspense>` |
1357
+ | `examples/astro` | Astro middleware and `.astro` components |
1358
+ | `examples/sveltekit` | `hooks.server.ts`, `+layout.server.ts`, `.svelte` components |
1359
+ | `examples/nuxt` | `server/middleware`, `useAsyncData`, `.vue` components |
1360
+ | `examples/express` | Vanilla core — a hand-written adapter for an unsupported framework |
973
1361
 
974
1362
  ## Migrating from `@preprio/prepr-nextjs`
975
1363