@preprio/toolkit 0.6.0-alpha.0 → 0.6.0-alpha.10

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 (59) hide show
  1. package/README.md +268 -59
  2. package/dist/astro/{chunk-3UVUDDNY.js → chunk-CG2QBHNU.js} +1 -1
  3. package/dist/astro/graphql.cjs +154 -59
  4. package/dist/astro/graphql.d.cts +75 -6
  5. package/dist/astro/graphql.d.ts +75 -6
  6. package/dist/astro/graphql.js +150 -59
  7. package/dist/astro/index.cjs +1 -1
  8. package/dist/astro/index.js +1 -1
  9. package/dist/chunk-3NCEVJJ3.js +10 -0
  10. package/dist/{chunk-BJ2LBLWO.js → chunk-LIDUJWWJ.js} +6 -4
  11. package/dist/chunk-M3RBYJ5J.js +499 -0
  12. package/dist/{chunk-DGZA7KX7.js → chunk-S5RWVMDJ.js} +0 -24
  13. package/dist/{chunk-YR7P4IWE.js → chunk-SF6TN4JR.js} +1 -9
  14. package/dist/chunk-TN6SNAR2.js +26 -0
  15. package/dist/graphql/codegen.cjs +557 -0
  16. package/dist/graphql/codegen.d.cts +23 -0
  17. package/dist/graphql/codegen.d.ts +23 -0
  18. package/dist/graphql/codegen.js +99 -0
  19. package/dist/graphql.cjs +185 -59
  20. package/dist/graphql.d.cts +110 -6
  21. package/dist/graphql.d.ts +110 -6
  22. package/dist/graphql.js +91 -430
  23. package/dist/index.cjs +1 -1
  24. package/dist/index.d.cts +1 -1
  25. package/dist/index.d.ts +1 -1
  26. package/dist/index.js +10 -6
  27. package/dist/nextjs/chunk-B4GVZEOL.js +549 -0
  28. package/dist/nextjs/components.cjs +297 -81
  29. package/dist/nextjs/components.d.cts +26 -11
  30. package/dist/nextjs/components.d.ts +26 -11
  31. package/dist/nextjs/components.js +289 -73
  32. package/dist/nextjs/graphql.cjs +952 -315
  33. package/dist/nextjs/graphql.d.cts +225 -83
  34. package/dist/nextjs/graphql.d.ts +225 -83
  35. package/dist/nextjs/graphql.js +634 -314
  36. package/dist/nextjs/index.cjs +116 -54
  37. package/dist/nextjs/index.d.cts +61 -23
  38. package/dist/nextjs/index.d.ts +61 -23
  39. package/dist/nextjs/index.js +97 -299
  40. package/dist/nextjs/{server-CjpELYvq.d.ts → server-BL-sqZvu.d.cts} +1 -1
  41. package/dist/nextjs/{server-CjpELYvq.d.cts → server-BL-sqZvu.d.ts} +1 -1
  42. package/dist/nuxt/{chunk-P6ENNAEE.js → chunk-FMC3DP7N.js} +1 -1
  43. package/dist/nuxt/graphql.cjs +154 -59
  44. package/dist/nuxt/graphql.d.cts +75 -6
  45. package/dist/nuxt/graphql.d.ts +75 -6
  46. package/dist/nuxt/graphql.js +150 -59
  47. package/dist/nuxt/index.cjs +1 -1
  48. package/dist/nuxt/index.js +1 -1
  49. package/dist/react/components.cjs +1 -1
  50. package/dist/react/components.js +1 -1
  51. package/dist/sveltekit/{chunk-FSARVMG5.js → chunk-AJPKBAHO.js} +1 -1
  52. package/dist/sveltekit/graphql.cjs +154 -59
  53. package/dist/sveltekit/graphql.d.cts +75 -6
  54. package/dist/sveltekit/graphql.d.ts +75 -6
  55. package/dist/sveltekit/graphql.js +150 -59
  56. package/dist/sveltekit/index.cjs +1 -1
  57. package/dist/sveltekit/index.js +1 -1
  58. package/package.json +6 -1
  59. package/dist/nextjs/chunk-U4HXENF7.js +0 -231
package/README.md CHANGED
@@ -225,7 +225,9 @@ Codegen's default client-preset output works as is; `documentMode: 'string'` wor
225
225
 
226
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
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.
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. With Cache Components, a webhook can refresh only the pages showing the changed item instead: see [Refreshing on publish](#refreshing-on-publish).
229
+
230
+ Tags you pass per query in `init.next.tags` are added to the client's `cacheTags`, never in place of them.
229
231
 
230
232
  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
233
 
@@ -233,11 +235,11 @@ Custom headers go in two places. Client-level `headers` (`createPreprClient({ ur
233
235
 
234
236
  #### Personalization with Cache Components
235
237
 
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.
238
+ With [Cache Components](https://nextjs.org/docs/app/getting-started/cache-components) (`cacheComponents: true`), a personalized page is still served from the cache, whole: every variant of every A/B test and personalization group is in the prerendered page, and each page view's decision shows the visitor's variant before the page paints. Nothing streams, and no default variant shows first to be swapped out. You keep your own query and your own components.
237
239
 
238
240
  **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
241
 
240
- A normal page needs three changes:
242
+ A normal page needs two changes, plus the [proxy](#the-proxy):
241
243
 
242
244
  ```tsx
243
245
  import { PreprStack } from '@preprio/toolkit/nextjs/graphql';
@@ -261,14 +263,10 @@ export default async function Page({
261
263
  const slug = (await params).slug?.join('/') || '/';
262
264
  const data = await getPage(slug);
263
265
 
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.
266
+ // 2. Render the list through PreprStack instead of directly.
268
267
  return (
269
268
  <PreprStack
270
269
  items={data.Page.content}
271
- decisions={decisions}
272
270
  render={(items, { index }) => <Sections items={items} offset={index} />}
273
271
  />
274
272
  );
@@ -288,7 +286,6 @@ Because `render` runs per block, a position counted inside `Sections` (`items.ma
288
286
  // After
289
287
  <PreprStack
290
288
  items={data.Page.content}
291
- decisions={decisions}
292
289
  render={(items, { index }) => <Sections items={items} offset={index} />}
293
290
  />
294
291
 
@@ -308,11 +305,11 @@ A group takes one position, whichever variant shows. A group that shows nothing
308
305
  | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
309
306
  | One page-builder list (`content`, `sections`, `stack`) | One `PreprStack` around it. |
310
307
  | 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`. |
308
+ | Several lists (hero, body, sidebar) | One `PreprStack` per list. |
312
309
  | A personalized list outside any list you already wrap (e.g. `Page.buttons`) | Its own `PreprStack`. |
313
310
  | Data used outside rendering (`generateMetadata`, JSON-LD) | `resolveVariants(data)` gives the default variants and A. |
314
311
  | 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. |
312
+ | No personalization on a page | Nothing to change. `PreprStack` renders it as is and the proxy sends no request. |
316
313
 
317
314
  ##### Impressions and conversions
318
315
 
@@ -321,7 +318,7 @@ Prepr's tracking pixel counts impressions of an element carrying `data-prepr-var
321
318
  Opt in with `trackVariants`, and `PreprStack` puts the key on the section each personalized and A/B variant renders:
322
319
 
323
320
  ```tsx
324
- <PreprStack items={data.Page.content} decisions={decisions} trackVariants render={…} />
321
+ <PreprStack items={data.Page.content} trackVariants render={…} />
325
322
  ```
326
323
 
327
324
  For variants nested inside a section (cards, buttons, models), and outside Next.js, spread `preprVariantProps(item)` on the element that shows the item:
@@ -335,62 +332,94 @@ Conversions stay your choice: put `data-prepr-variant-event` (or `data-prepr-var
335
332
  - 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
333
  - 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
334
  - 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.
335
+ - `trackVariants` tags only the variant that is shown, once it is shown. A section holding a personalized list (a logo strip whose logos vary) gets the key of the variant it shows. Do not tag stack sections yourself (with your own `_context { variant_key }` or `preprVariantProps`): that puts a key on every variant in the page, including the invisible default that holds its space while the decision is on its way, and Prepr's pixel counts any tagged element in view, invisible or not.
347
336
 
348
- ##### A/B tests
337
+ ##### The proxy
349
338
 
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:
339
+ One call in `proxy.ts` gives `PreprStack` what it needs: the visitor's Prepr headers and customer id, and each page view's decision: the visitor's variant of every A/B test and personalization group on a page, in one Prepr request. Only the first load of a URL by a visitor waits for it; see [How each visitor gets their variant](#how-each-visitor-gets-their-variant). Tell it which query each URL renders; most sites route every page through one query:
351
340
 
352
341
  ```ts
353
342
  // 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 }),
343
+ import { createPreprProxy } from '@preprio/toolkit/nextjs/graphql';
344
+
345
+ const handlePrepr = createPreprProxy({
346
+ client: prepr,
347
+ // URL → the query that page renders with `variants: true`; null for non-CMS URLs.
348
+ page: (url) => ({
349
+ document: PageDocument,
350
+ variables: { slug: url.pathname },
351
+ }),
352
+ preview: process.env.PREPR_ENV === 'preview',
359
353
  });
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
354
 
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:
355
+ export function proxy(request: NextRequest) {
356
+ return handlePrepr(request); // add your own cookies or headers to this response
357
+ }
358
+ ```
368
359
 
369
360
  ```tsx
370
- <PreprStack items={data.Page.content} decisions={decisions} aboveTheFold={1} render={…} />
361
+ // app/layout.tsx, once: decides every client-side navigation
362
+ import { PreprPersonalizationSwitch } from '@preprio/toolkit/nextjs';
363
+
364
+ <PreprPersonalizationSwitch />;
371
365
  ```
372
366
 
367
+ Deriving the decision query from `PageDocument` needs Prepr's schema, which a fresh server instance downloads before its first decision. Build the decision query ahead of time instead and pass it as `decision`: the codegen plugin (see [Using your own GraphQL client](#using-your-own-graphql-client)) writes it next to your query, and the proxy sends it as is.
368
+
373
369
  ```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
- );
370
+ import { PageDecisionQuery } from './gql/prepr-queries';
371
+
372
+ page: (url) => ({
373
+ document: PageDocument,
374
+ decision: PageDecisionQuery, // no schema download at runtime
375
+ variables: { slug: url.pathname },
376
+ }),
385
377
  ```
386
378
 
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.
379
+ API routes, `/_next/` and URLs whose last segment has a file extension (`/logo.png`, but also `/docs/v1.2`) are skipped. A decision request for any other URL `page` maps to a query sends one decision query; a page without groups gets an empty answer. Pass `onlyIf: (request) => boolean` to skip Prepr for some requests, such as crawlers, which then get the default variants. In preview it also sends the editor through the [draft route](#preview) first. `createPreprMiddleware` and `preprDraftRedirect` stay exported for proxies that need them separately; decision requests are answered by `createPreprProxy` only.
380
+
381
+ ##### A/B tests
382
+
383
+ A/B tests go through the same decision as segment groups: both variants are in the prerendered page, hidden until the page view's decision shows the visitor's one. Prepr assigns a variant per experiment, from the visitor's customer id and that experiment's split, so the decision is the only reliable source; one stored bucket per visitor cannot follow a 20/80 and a 50/50 test at once. A is the default, also for crawlers and when Prepr cannot be asked. Pass `bucket` to `PreprStack` only when the route already knows it. Under a strict Content-Security-Policy, pass `nonce` to `PreprStack`.
384
+
385
+ ##### How each visitor gets their variant
386
+
387
+ Every page view is decided, and its personalized sections paint with the visitor's variant. Decisions are kept per URL in the visitor's `prepr_decisions` cookie, which the proxy and the browser both read and write (see **Reuse** below); nothing is reused from a prefetch, the router cache or another URL's view.
388
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.
389
+ - **Page loads.** The page is the same for every visitor and comes from the cache. When the cookie holds no decision for the URL, the proxy asks Prepr before sending it (one request, at most `timeoutMs`) and puts the answer in the cookie; otherwise the page passes straight through. An inline script at the start of each `PreprStack` reads the cookie and shows the visitor's variants before the page paints. So only a visitor's first load of each personalized URL waits for Prepr, on the server, where the browser keeps showing the previous page instead of an empty section. Pages without personalization are remembered by the proxy and never wait.
390
+ - **Client-side navigations.** When the visitor hovers over a link (or presses it, on touch devices), `PreprPersonalizationSwitch` asks the proxy for the target page's decision. A click whose decision is in, which hovering and the cookie mostly ensure, goes through at once; one without (a quick tap, a link reached by keyboard) waits for it, so the new page always paints with the visitor's variants. Links to pages without personalization never wait. Prefetches still bring the page itself; only the decision is fetched.
391
+ - **Never the default first.** Until a view's decision is in, its personalized sections are not shown: the default variant keeps its space, invisible, so the page does not move when the visitor's variant appears (only by any difference in height). The default variant only shows when it is the decision: for visitors in none of the segments, for requests `onlyIf` skips, and when Prepr does not answer within `timeoutMs` (default 1000). Navigations started from code to a URL without a decision ask for one when the page opens; its personalized sections appear when it arrives.
392
+ - **Reuse.** Back and Forward show a page as this tab last showed it, at once, without asking Prepr. Any other view of a URL the cookie holds a decision for shows it at once too: under 10 seconds old without asking again, since Prepr needs a few seconds to process a visitor's activity anyway; up to 30 minutes old while a new decision is asked for in the background. When that one differs, it changes only the sections still below the screen; a section the visitor can see, or has scrolled past, keeps its variant for that view, so nothing they are reading is swapped or moves under them. The next view shows the new decision. A page without personalization answers with no groups, which is trusted for an hour with no background check, so links to it cost nothing; when that page renders personalization by then (published since), it asks at once. A URL with other UTM parameters is another view; a failed answer is kept for 10 seconds and never replaces a real one.
393
+ - **"Right" means what Prepr knows when the visitor clicks.** Prepr assigns segments from the visitor's tracked activity. If it has not yet processed the pixel event of the previous page view, the decision cannot include it, and the visitor sees that segment from a later page view.
394
+ - **Costs.** The page carries the HTML of every variant, and an item with groups nested inside it once per combination of their variants. `render` gets `defaultVariant`: give `priority` images only to the default variant and `loading="lazy"` to the others, or the browser downloads every variant's hero image. Every page load with personalization, and every link hovered or pressed (also a press that turns into a scroll), is at most one decision request when this tab has no fresh decision for that URL, answered from Prepr's cache when other visitors had the same answer.
395
+ - Without JavaScript, visitors see the default variants.
396
+
397
+ ##### Deploying
398
+
399
+ Pages are sent without waiting for Prepr; only the personalized sections wait for their decision, and most of that time is spent at Prepr, in two steps:
400
+
401
+ - **Who is the visitor?** For a visitor Prepr has not seen before, it first works out their segments, which takes a few hundred milliseconds on pages with segment groups. A returning visitor is resolved at once.
402
+ - **The answer.** Prepr's CDN caches each decision per page, segment set and A/B bucket, and shares it between visitors who get the same. A cached answer takes tens of milliseconds; working one out takes most of a second, once per page and combination until Prepr's cache drops it.
403
+
404
+ Under steady traffic most answers come from that cache. Your deployment adds the rest:
405
+
406
+ - **Region.** Run your functions, the proxy included, in the region closest to your visitors and to Prepr.
407
+ - **Keep connections open.** Node.js closes idle connections after a few seconds. On the Node.js runtime, give the client a `fetch` that keeps them longer; this affects only the client's own requests:
408
+
409
+ ```ts
410
+ import { Agent } from 'undici';
411
+
412
+ const keepAlive = new Agent({ keepAliveTimeout: 60_000 });
413
+ export const prepr = createPreprClient({
414
+ url: process.env.PREPR_GRAPHQL_URL!,
415
+ fetch: (input, init) =>
416
+ fetch(input, { ...init, dispatcher: keepAlive } as RequestInit),
417
+ });
418
+ ```
419
+
420
+ - **Instances.** On Vercel, Fluid Compute reuses a function instance, and its open connections, across requests, so under steady traffic the connection stays warm. Each fresh instance pays the setup on its first request; keep instances warm with your platform's settings if your traffic is low.
421
+ - **No schema at runtime.** Pass the codegen plugin's decision query as `decision` (see [The proxy](#the-proxy)), so a fresh instance does not download Prepr's schema before its first decision.
422
+ - **Timeout.** When Prepr does not answer within `timeoutMs` (default 1000), the personalized sections show the default variants, with nothing swapped in later. It caps how long a first page load waits for Prepr.
394
423
 
395
424
  ##### Preview
396
425
 
@@ -405,17 +434,84 @@ const draft = preprDraftRedirect(request, { preview });
405
434
  if (draft) return draft;
406
435
  ```
407
436
 
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')`.
437
+ 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 (below) in place.
438
+
439
+ ##### Refreshing on publish
440
+
441
+ `revalidateTag('prepr')` refreshes every Prepr page on every publish. To refresh only the pages that show the changed item, tag each cached page with its items and let a Prepr webhook revalidate the item's tag.
442
+
443
+ 1. **Tag the page with its items.** Add `tagItems: true` to the query inside `'use cache'`. The entry gets a `prepr:<_id>` tag for every item in the result, at any depth; with `variants: true` that is every variant's items, so a change to any variant reaches the page.
444
+
445
+ ```tsx
446
+ async function getPage(slug: string) {
447
+ 'use cache';
448
+ const { data } = await prepr().query(
449
+ GetPageBySlugDocument,
450
+ { slug },
451
+ { variants: true, tagItems: true },
452
+ );
453
+ return data;
454
+ }
455
+ ```
456
+
457
+ With your own client, call `cacheTag(...preprCacheTags(data))` inside `'use cache'` instead.
458
+
459
+ 2. **New items are covered too.** A new item is in no cached result yet, so `tagItems` also tags two kinds of query with their model's `prepr:model:<model>` tag, which a publish of any item of that model revalidates:
460
+ - list queries (`Posts { items { … } }`), so a new post joins the list;
461
+ - lookups that found nothing (`Page(slug: "new")` returning `null`), so publishing that page replaces the cached "not found".
462
+
463
+ Other pages that depend on which items exist (a sitemap, a count) can add the tag themselves:
464
+
465
+ ```ts
466
+ import { cacheTag } from 'next/cache';
467
+ import { preprModelTag } from '@preprio/toolkit/nextjs/graphql';
468
+
469
+ cacheTag(preprModelTag('Post')); // the model's name in Prepr or its GraphQL type
470
+ ```
471
+
472
+ The model name is compared without case, spaces or punctuation, so `'Blog post'` (what webhooks send) and `BlogPost` (the GraphQL type) give the same tag. A model whose API ID was renamed away from its name does not match.
473
+
474
+ 3. **Add the webhook route.**
475
+
476
+ ```ts
477
+ // app/api/prepr/webhook/route.ts
478
+ import { createPreprWebhookRoute } from '@preprio/toolkit/nextjs';
479
+
480
+ export const POST = createPreprWebhookRoute({
481
+ secret: process.env.PREPR_WEBHOOK_SECRET,
482
+ });
483
+ ```
484
+
485
+ Keep the route out of your proxy `matcher`.
486
+
487
+ 4. **Create the webhook in Prepr.** Under **Settings → Webhooks**, add `https://<your-site>/api/prepr/webhook` with the events `content_item.published`, `content_item.unpublished`, `content_item.deleted` and `content_item.invalidated`, plus `asset.changed`, `asset.deleted` and `schema.changed`. Other events are acknowledged and ignored, so subscribing to more does no harm. Save, then copy the webhook's **ID** into `PREPR_WEBHOOK_SECRET`: Prepr signs every request with it.
488
+
489
+ The route checks the `Prepr-Signature` header (an HMAC-SHA256 of the body, keyed with the webhook ID, as hex or base64) and answers 401 to a missing or wrong signature, or to a request older than five minutes. A valid event revalidates:
490
+
491
+ | Tag | When | Reaches |
492
+ | --------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
493
+ | `prepr:<id>` | `content_item.published`, `.unpublished`, `.deleted`, `.invalidated`; `asset.changed`, `.deleted` | pages showing that item or asset |
494
+ | `prepr:model:<model>` | `content_item.published`, `.unpublished`, `.deleted` | list queries, lookups that found nothing, pages tagged by you |
495
+ | `prepr:any` | the same events as `prepr:<id>` | pages with more items than Next.js's tag limit |
496
+ | `prepr:schema` | `schema.changed` | the schema the client rewrites queries with |
497
+
498
+ Draft edits (`content_item.changed`, `.created`), visitor and segment events revalidate nothing: they change no published page.
499
+
500
+ Revalidation uses `revalidateTag(tag, { expire: 0 })`, so the next request after a publish gets the new page. Pass `profile: 'max'` to serve the old page once more while the new one renders in the background.
501
+
502
+ Next.js accepts at most 128 tags per `cacheTag()` call. A page with more items is tagged `prepr:any` instead (`PREPR_ANY_ITEM_TAG`), so it refreshes on every event, as with `revalidateTag('prepr')`. The response lists the revalidated tags, which Prepr shows on the webhook's detail page.
503
+
504
+ `tagItems` makes that query skip the Data Cache: a Data Cache entry carries only the client's `cacheTags`, so after an item tag is revalidated, the `'use cache'` function would re-run against the stale response. The `'use cache'` entry is the cache. The client's `cacheTags` still apply to the entry, so `revalidateTag('prepr')` keeps refreshing everything. `tagItems` needs Next.js 16 and throws outside a `'use cache'` function.
409
505
 
410
506
  <details>
411
507
  <summary>How it works</summary>
412
508
 
413
509
  - **`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.
510
+ - **Decisions** send your query cut down to the lists that hold segment groups, 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. `prepr.decide()` does the same inside a render, for rendering variants yourself with `resolveVariants`; `PreprStack` does not need it.
511
+ - **`PreprStack`** renders every variant of each segment group, and each item with groups nested inside it once per combination of their variants, in `display: contents` wrappers that stay hidden until the view's decision marks one to show. A group with no default variant still gets its place.
512
+ - **A/B variants** are both in the HTML and picked by the same decision as segment groups. A hidden variant never becomes visible, so the tracking pixel records no impression for it.
417
513
  - **`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.
514
+ - **Tags carry over.** The client's `'prepr'` fetch tag applies to the `'use cache'` entry, so `revalidateTag('prepr')` refreshes it without a `cacheTag()` call. `tagItems` adds a tag per item; see [Refreshing on publish](#refreshing-on-publish).
419
515
  - **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
516
 
421
517
  </details>
@@ -424,6 +520,102 @@ Outside preview both do nothing (the route answers 404). Keep the route out of y
424
520
 
425
521
  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
522
 
523
+ #### Using your own GraphQL client
524
+
525
+ 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`.
526
+
527
+ 1. **Variants query** (same for every visitor, cache it): `toVariantsQuery(query, schema)`.
528
+ 2. **Decision query** (per visitor, never cached, with the visitor's Prepr headers): `toDecisionQuery(query, schema, { from: variantsData })`, then `decisionsFrom(data)`.
529
+ 3. **Render** with `PreprStack` (Next.js) or `resolveVariants(data, { decisions, bucket })` anywhere.
530
+
531
+ The rewrites need to know which fields take `personalize`: `loadPreprSchema(url)` fetches that once and keeps it in memory.
532
+
533
+ ```ts
534
+ import {
535
+ decisionsFrom,
536
+ loadPreprSchema,
537
+ resolveVariants,
538
+ toDecisionQuery,
539
+ toVariantsQuery,
540
+ } from '@preprio/toolkit/graphql';
541
+
542
+ const url = process.env.PREPR_GRAPHQL_URL!;
543
+ const schema = await loadPreprSchema(url);
544
+
545
+ async function post(
546
+ query: string,
547
+ variables: object,
548
+ headers: HeadersInit = {},
549
+ ) {
550
+ const res = await fetch(url, {
551
+ method: 'POST',
552
+ headers: { 'Content-Type': 'application/json', ...headers },
553
+ body: JSON.stringify({ query, variables }),
554
+ });
555
+ const json = await res.json();
556
+ if (!res.ok || json.errors)
557
+ throw new Error(`Prepr GraphQL request failed (${res.status})`);
558
+ return json.data;
559
+ }
560
+
561
+ const variables = { slug: '/' };
562
+ const page = await post(toVariantsQuery(PAGE_QUERY, schema), variables); // cacheable
563
+
564
+ const decisionQuery = toDecisionQuery(PAGE_QUERY, schema, { from: page });
565
+ const decisions = decisionQuery
566
+ ? decisionsFrom(await post(decisionQuery, variables, visitorPreprHeaders))
567
+ : new Map();
568
+
569
+ const content = resolveVariants(page.Page.content, { decisions, bucket });
570
+ ```
571
+
572
+ `visitorPreprHeaders` are the headers the middleware forwards (`getPreprHeaders()` in Next.js); `bucket` is the visitor's A/B bucket, when you know it; leave it out and A/B tests use the decision too (see below).
573
+
574
+ With Apollo, parse the strings once and use them like any document:
575
+
576
+ ```ts
577
+ import { gql } from '@apollo/client';
578
+
579
+ const PageVariants = gql(toVariantsQuery(PAGE_QUERY, schema));
580
+ const { data: page } = await client.query({ query: PageVariants, variables });
581
+
582
+ const decisionQuery = toDecisionQuery(PAGE_QUERY, schema, { from: page });
583
+ const decisions = decisionQuery
584
+ ? decisionsFrom(
585
+ (
586
+ await client.query({
587
+ query: gql(decisionQuery),
588
+ variables,
589
+ context: { headers: visitorPreprHeaders },
590
+ fetchPolicy: 'no-cache', // per visitor: never from Apollo's cache
591
+ })
592
+ ).data,
593
+ )
594
+ : new Map();
595
+ ```
596
+
597
+ **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:
598
+
599
+ ```ts
600
+ // codegen.ts
601
+ generates: {
602
+ 'src/gql/prepr-queries.ts': {
603
+ plugins: ['@preprio/toolkit/graphql/codegen'],
604
+ },
605
+ },
606
+ ```
607
+
608
+ ```ts
609
+ import {
610
+ GetPageBySlugDecisionQuery,
611
+ GetPageBySlugVariantsQuery,
612
+ } from './gql/prepr-queries';
613
+ ```
614
+
615
+ 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.
616
+
617
+ A decision query without `from` also returns the visitor's variant of each A/B test, per experiment and its split; `from` limits it to the lists with segment groups. Otherwise pass a known bucket to `resolveVariants`.
618
+
427
619
  ### Image loader
428
620
 
429
621
  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`.
@@ -819,12 +1011,25 @@ Each wrapper exposes the same five helpers, differing only in how they reach the
819
1011
  | `cacheTags` (Next.js) | `['prepr']` | Tags on cacheable fetches. |
820
1012
  | `revalidate` (Next.js) | unset | `next.revalidate` on cacheable fetches. |
821
1013
 
822
- `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.
1014
+ `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), `tagItems` (Next.js 16, inside `'use cache'`) and `event` (Nuxt, SvelteKit, Astro). A request carrying per-query headers or `init.headers` is `no-store`; client-level `headers` are not.
823
1015
 
824
1016
  Import `PreprError` from the same subpath as the client when checking `instanceof`: CommonJS entry points do not share classes. `error.code` works from anywhere.
825
1017
 
826
1018
  `isPersonalizedResponse(response)`, exported from every entry point, reads the same verdict from a response fetched with your own client. It does not need `graphql`.
827
1019
 
1020
+ For your own GraphQL client (see [Using your own GraphQL client](#using-your-own-graphql-client)), every `/graphql` subpath also exports:
1021
+
1022
+ | Function | Returns |
1023
+ | ---------------------------------------------------------- | -------------------------------------------------------------------------------------- |
1024
+ | `loadPreprSchema(url, { fetch? })` | What the rewrites need from your schema, loaded once per URL. Failures are not cached. |
1025
+ | `toVariantsQuery(query, schema)` | The variants query as a string: what `variants: true` sends. |
1026
+ | `toDecisionQuery(query, schema, { from?, topLevelOnly? })` | The per-visitor decision query as a string, or `null` when there is nothing to decide. |
1027
+ | `decisionsFrom(data)` | Decisions from a decision query's `data`, for `resolveVariants` or `PreprStack`. |
1028
+ | `preprCacheTags(data)` | `prepr:<_id>` for every item in a result, or `['prepr:any']` past 128 items. |
1029
+ | `preprModelTag(model)` | `prepr:model:<model>`, for list pages. |
1030
+
1031
+ `@preprio/toolkit/graphql/codegen` is a graphql-codegen plugin that writes `<Name>VariantsQuery` and `<Name>DecisionQuery` for every named query with personalizable fields.
1032
+
828
1033
  ### Token helpers
829
1034
 
830
1035
  Exported from every entry point, including the core.
@@ -847,6 +1052,10 @@ The toolbar. Takes the props returned by `getToolbarProps`, and renders nothing
847
1052
  <PreprToolbar {...toolbarProps} />
848
1053
  ```
849
1054
 
1055
+ #### `PreprPersonalizationSwitch`
1056
+
1057
+ From `@preprio/toolkit/nextjs`. Gets each client-side navigation's decision in before the click: when the visitor hovers over a same-site link (or presses it, on touch devices), it asks `createPreprProxy` for the target page's decision. A click whose decision is not in yet waits for it (see [How each visitor gets their variant](#how-each-visitor-gets-their-variant)). Mount it once in the root layout, so links on pages without a `PreprStack` are covered too. Links to other sites and links that open a new tab or download are left alone. The decision requests are answered by `createPreprProxy` only. Takes no props and renders nothing.
1058
+
850
1059
  #### `PreprPreview`
851
1060
 
852
1061
  From `@preprio/toolkit/react`. The framework-free equivalent of `PreprToolbar`: same preview runtime, with the router binding left to you. Renders nothing.
@@ -1,5 +1,5 @@
1
1
  // src/version.ts
2
- var VERSION = "0.6.0-alpha.0";
2
+ var VERSION = "0.6.0-alpha.10";
3
3
 
4
4
  // src/core/features.ts
5
5
  function isEnabled(config) {