@preprio/toolkit 0.2.0-beta.7 → 0.2.0-beta.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,23 +1,30 @@
1
1
  # @preprio/toolkit
2
2
 
3
- A framework-agnostic TypeScript library that provides preview functionality, visual editing capabilities, and A/B testing for [Prepr CMS](https://prepr.io). Ships thin wrappers for Next.js, Nuxt, Astro, and SvelteKit on top of a vanilla core that runs anywhere.
3
+ A framework-agnostic TypeScript library that provides preview functionality, visual editing, and A/B testing for [Prepr CMS](https://prepr.io). Ships thin wrappers for Next.js, Nuxt, Astro, and SvelteKit on top of a vanilla core that runs anywhere.
4
4
 
5
- ## Quick Start
5
+ Every integration is the same three steps:
6
+
7
+ 1. **Middleware** — resolves the visitor's customer ID, segment, and A/B variant into request headers.
8
+ 2. **Layout** — mounts the preview toolbar and the tracking pixel.
9
+ 3. **Data fetching** — forwards the Prepr headers on your GraphQL requests, so Prepr returns personalized content.
10
+
11
+ Jump to your framework: [Next.js](#nextjs) · [Astro](#astro) · [SvelteKit](#sveltekit) · [Nuxt](#nuxt) · [React without a framework](#react-no-framework) · [anything else](#any-other-framework).
12
+
13
+ ## Quick Start (Next.js)
6
14
 
7
15
  ```bash
8
- # Install the package
9
16
  npm install @preprio/toolkit
10
17
  # or
11
18
  pnpm add @preprio/toolkit
12
19
  ```
13
20
 
14
- Add your Prepr GraphQL URL to your `.env`:
21
+ Add your Prepr GraphQL URL (Settings → Access tokens in Prepr) to your `.env`. The toolkit reads **no environment variables itself** — the name is yours; you pass the value in explicitly:
15
22
 
16
23
  ```bash
17
24
  PREPR_GRAPHQL_URL=https://graphql.prepr.io/{YOUR_ACCESS_TOKEN}
18
25
  ```
19
26
 
20
- Set up middleware in `middleware.ts`:
27
+ **`middleware.ts`**
21
28
 
22
29
  ```typescript
23
30
  import type { NextRequest } from 'next/server';
@@ -35,7 +42,7 @@ export const config = {
35
42
  };
36
43
  ```
37
44
 
38
- Add the toolbar and tracking pixel to your layout:
45
+ **`app/layout.tsx`**
39
46
 
40
47
  ```tsx
41
48
  import type { ReactNode } from 'react';
@@ -46,7 +53,11 @@ import {
46
53
  PreprTrackingPixel,
47
54
  } from '@preprio/toolkit/nextjs';
48
55
 
49
- export default async function RootLayout({ children }: { children: ReactNode }) {
56
+ export default async function RootLayout({
57
+ children,
58
+ }: {
59
+ children: ReactNode;
60
+ }) {
50
61
  const preview = process.env.VERCEL_ENV !== 'production';
51
62
  const toolbarProps = preview
52
63
  ? await getToolbarProps(process.env.PREPR_GRAPHQL_URL!)
@@ -65,118 +76,67 @@ export default async function RootLayout({ children }: { children: ReactNode })
65
76
  }
66
77
  ```
67
78
 
68
- No CSS import is needed. The toolbar renders into a shadow-DOM custom element (`<prepr-toolbar>`) with its styles inlined, so it can neither leak into nor inherit from your page's stylesheets.
69
-
70
- ## Prerequisites
71
-
72
- Before installing, ensure you have:
73
-
74
- - **Node.js 18.0.0 or later**
75
- - **A Prepr account**
76
- - **A Prepr GraphQL URL** (found in Settings → Access tokens)
79
+ **Data fetching** — spread `getPreprHeaders()` into every Prepr GraphQL request:
77
80
 
78
- Per-framework requirements:
79
-
80
- | Entry point | Peer dependencies |
81
- | --- | --- |
82
- | `@preprio/toolkit` (vanilla core) | none |
83
- | `@preprio/toolkit/react` | `react` >= 17, `react-dom` >= 17 |
84
- | `@preprio/toolkit/nextjs` | `next` >= 13, `react` >= 17, `react-dom` >= 17 |
85
- | `@preprio/toolkit/astro` | none (`.astro` components compile in your own pipeline) |
86
- | `@preprio/toolkit/sveltekit` | `svelte` (only for the `.svelte` components) |
87
- | `@preprio/toolkit/nuxt` | `vue` (only for the `.vue` components) |
88
-
89
- All peer dependencies are optional — installing without React or Next.js is fine as long as you only import the entry points you have dependencies for.
90
-
91
- ### Prepr Account Setup
92
-
93
- 1. **Create a Prepr account** at [prepr.io](https://prepr.io)
94
- 2. **Get your GraphQL URL**:
95
- - Go to Settings → Access tokens
96
- - Find your GraphQL Preview access token
97
- - Copy the full GraphQL URL (e.g. `https://graphql.prepr.io/e6f7a0521f11e5149ce65b0e9f372ced2dfc923490890e7f225da1db84cxxxxx`)
98
- - The URL format is always `https://graphql.prepr.io/{YOUR_ACCESS_TOKEN}`
99
- 3. **Enable edit mode** (required for click-to-edit in the toolbar):
100
- - Open your GraphQL Preview access token
101
- - Check "Enable edit mode"
102
- - Save the token
103
-
104
- ## Installation & Setup
105
-
106
- ### 1. Install the Package
81
+ ```typescript
82
+ import { getPreprHeaders } from '@preprio/toolkit/nextjs';
107
83
 
108
- ```bash
109
- npm install @preprio/toolkit
110
- # or
111
- pnpm add @preprio/toolkit
112
- # or
113
- yarn add @preprio/toolkit
84
+ const response = await fetch(process.env.PREPR_GRAPHQL_URL!, {
85
+ method: 'POST',
86
+ headers: {
87
+ 'Content-Type': 'application/json',
88
+ ...(await getPreprHeaders()),
89
+ },
90
+ body: JSON.stringify({ query, variables }),
91
+ cache: 'no-store',
92
+ });
114
93
  ```
115
94
 
116
- ### 2. Environment Configuration
95
+ No CSS import is needed. The toolbar renders into a shadow-DOM custom element (`<prepr-toolbar>`) with its styles inlined, so it can neither leak into nor inherit from your page's stylesheets.
117
96
 
118
- Create or update your `.env` file:
97
+ ## Prerequisites
119
98
 
120
- ```bash
121
- # Required: your Prepr GraphQL endpoint
122
- PREPR_GRAPHQL_URL=https://graphql.prepr.io/{YOUR_ACCESS_TOKEN}
123
- ```
99
+ - **Node.js 18.17 or later**
100
+ - **A Prepr account** — [prepr.io](https://prepr.io)
101
+ - **A Prepr GraphQL URL**:
102
+ 1. Go to Settings → Access tokens and open your **GraphQL Preview** access token.
103
+ 2. Copy the full GraphQL URL — always `https://graphql.prepr.io/{YOUR_ACCESS_TOKEN}`.
104
+ 3. Check **"Enable edit mode"** on the token and save (required for click-to-edit).
124
105
 
125
- > **Important**: Replace `{YOUR_ACCESS_TOKEN}` with your actual Prepr access token from Settings → Access tokens.
106
+ Per-framework peer dependencies — all optional; installing without them is fine as long as you only import the entry points you have dependencies for:
126
107
 
127
- The toolkit reads **no environment variables at all**. It has no opinion on the *name* of the URL variable either — `getToolbarProps(token)` takes the URL as a plain string, so store it wherever you like.
108
+ | Entry point | Peer dependencies |
109
+ | --------------------------------- | ------------------------------------------------------- |
110
+ | `@preprio/toolkit` (vanilla core) | none |
111
+ | `@preprio/toolkit/react` | `react` >= 17, `react-dom` >= 17 |
112
+ | `@preprio/toolkit/nextjs` | `next` >= 13, `react` >= 17, `react-dom` >= 17 |
113
+ | `@preprio/toolkit/astro` | none (`.astro` components compile in your own pipeline) |
114
+ | `@preprio/toolkit/sveltekit` | `svelte` (only for the `.svelte` components) |
115
+ | `@preprio/toolkit/nuxt` | `vue` (only for the `.vue` components) |
128
116
 
129
- ### 3. The preview gate
117
+ ## The preview gate
130
118
 
131
- Preview mode is controlled by exactly one thing: the `preview` option you pass to the middleware.
119
+ Three rules hold across every framework:
132
120
 
133
- ```typescript
134
- createPreprMiddleware(request, { preview: process.env.VERCEL_ENV !== 'production' });
135
- ```
121
+ 1. **The toolkit reads no environment variables.** Every value reaches it as an explicit argument, so behavior is identical across frameworks and bundlers. Because the GraphQL URL embeds an access token, keep it server-side unless you intend it to be public.
136
122
 
137
- Resolve that boolean however your deployment already distinguishes environments — `VERCEL_ENV`, `NODE_ENV`, SvelteKit's `dev`, Astro's `import.meta.env.DEV`, a branch check, or your own feature flag. Because the toolkit reads nothing from the environment itself, the behaviour is identical across every framework and every bundler.
123
+ 2. **Preview mode is one boolean: the `preview` option you pass to the middleware.** Resolve it from whatever already distinguishes your environments — `VERCEL_ENV`, `NODE_ENV`, SvelteKit's `dev`, Astro's `import.meta.env.DEV`, a branch check, your own feature flag. A hardcoded `{ preview: true }` enables preview everywhere, **production included**.
138
124
 
139
- `getToolbarProps` is ungated in all frameworks — it fetches whenever you call it. Call it only when your preview flag is on:
125
+ 3. **`getToolbarProps` is ungated — it fetches whenever you call it.** Gate the call on the same flag you pass the middleware:
140
126
 
141
- ```typescript
142
- const toolbarProps = preview ? await getToolbarProps(token) : null;
143
- ```
127
+ ```typescript
128
+ const toolbarProps = preview ? await getToolbarProps(token) : null;
129
+ ```
144
130
 
145
- > **Note:** a hardcoded `{ preview: true }` enables preview everywhere, production included. Always resolve it from an environment signal.
131
+ It never throws: a failed segment fetch degrades to an empty segment list, so no `try`/`catch` or error boundary is required.
146
132
 
147
133
  ## Next.js
148
134
 
149
- ### Middleware Setup
150
-
151
- The middleware assigns the visitor's customer ID, captures UTM parameters, and resolves the active segment and A/B variant into request headers.
152
-
153
- Create or update `middleware.ts` in your project root:
154
-
155
- ```typescript
156
- import type { NextRequest } from 'next/server';
157
- import { createPreprMiddleware } from '@preprio/toolkit/nextjs';
158
-
159
- export function middleware(request: NextRequest) {
160
- return createPreprMiddleware(request, { preview: true });
161
- }
162
-
163
- export const config = {
164
- matcher: [
165
- /*
166
- * Match all request paths except for the ones starting with:
167
- * - api (API routes)
168
- * - _next/static (static files)
169
- * - _next/image (image optimization files)
170
- * - favicon.ico (favicon file)
171
- */
172
- '/((?!api|_next/static|_next/image|favicon.ico).*)',
173
- ],
174
- };
175
- ```
135
+ The [Quick Start](#quick-start-nextjs) above is the complete basic setup. This section covers the extras.
176
136
 
177
137
  Despite the `create` prefix, `createPreprMiddleware` runs per request — it does not return a handler.
178
138
 
179
- #### Chaining with Existing Middleware
139
+ ### Chaining with existing middleware
180
140
 
181
141
  Pass an existing `NextResponse` as the second argument to fold Prepr's headers and cookies onto a response another middleware already produced:
182
142
 
@@ -184,6 +144,8 @@ Pass an existing `NextResponse` as the second argument to fold Prepr's headers a
184
144
  import { NextResponse, type NextRequest } from 'next/server';
185
145
  import { createPreprMiddleware } from '@preprio/toolkit/nextjs';
186
146
 
147
+ const preview = process.env.VERCEL_ENV !== 'production';
148
+
187
149
  export function middleware(request: NextRequest) {
188
150
  const response = NextResponse.next();
189
151
 
@@ -191,11 +153,11 @@ export function middleware(request: NextRequest) {
191
153
  response.headers.set('x-admin-route', 'true');
192
154
  }
193
155
 
194
- return createPreprMiddleware(request, response, { preview: true });
156
+ return createPreprMiddleware(request, response, { preview });
195
157
  }
196
158
  ```
197
159
 
198
- ##### With next-intl
160
+ With next-intl (return its redirects before Prepr runs):
199
161
 
200
162
  ```typescript
201
163
  import type { NextRequest } from 'next/server';
@@ -207,129 +169,26 @@ const intlMiddleware = createIntlMiddleware({
207
169
  defaultLocale: 'en',
208
170
  });
209
171
 
172
+ const preview = process.env.VERCEL_ENV !== 'production';
173
+
210
174
  export function middleware(request: NextRequest) {
211
175
  const intlResponse = intlMiddleware(request);
212
176
 
213
- // If next-intl returns a redirect, return it immediately.
214
177
  if (intlResponse.status >= 300 && intlResponse.status < 400) {
215
178
  return intlResponse;
216
179
  }
217
180
 
218
- return createPreprMiddleware(request, intlResponse, { preview: true });
181
+ return createPreprMiddleware(request, intlResponse, { preview });
219
182
  }
220
-
221
- export const config = {
222
- matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
223
- };
224
183
  ```
225
184
 
226
- ##### Conditional chaining
185
+ To skip Prepr for some routes entirely, return early with `NextResponse.next()` before calling `createPreprMiddleware` — or exclude them in `config.matcher`.
227
186
 
228
- ```typescript
229
- import { NextResponse, type NextRequest } from 'next/server';
230
- import { createPreprMiddleware } from '@preprio/toolkit/nextjs';
187
+ ### Tracking pixel
231
188
 
232
- export function middleware(request: NextRequest) {
233
- // Skip Prepr entirely for API routes.
234
- if (request.nextUrl.pathname.startsWith('/api/')) {
235
- return NextResponse.next();
236
- }
237
-
238
- return createPreprMiddleware(request, { preview: process.env.VERCEL_ENV !== 'production' });
239
- }
240
- ```
189
+ 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.
241
190
 
242
- ### Layout Integration
243
-
244
- `getToolbarProps` is ungated, so gate the call on the same flag you pass to the middleware:
245
-
246
- ```tsx
247
- import type { ReactNode } from 'react';
248
- import { getToolbarProps, PreprToolbar } from '@preprio/toolkit/nextjs';
249
-
250
- export default async function RootLayout({ children }: { children: ReactNode }) {
251
- const preview = process.env.VERCEL_ENV !== 'production';
252
- const toolbarProps = preview
253
- ? await getToolbarProps(process.env.PREPR_GRAPHQL_URL!)
254
- : null;
255
-
256
- return (
257
- <html lang="en">
258
- <body>
259
- {children}
260
- {toolbarProps && <PreprToolbar {...toolbarProps} />}
261
- </body>
262
- </html>
263
- );
264
- }
265
- ```
266
-
267
- Skipping the call outside preview means no API call and no toolbar. `getToolbarProps` also never throws — a failed segment fetch degrades to an empty segment list rather than crashing the host app — so no `try`/`catch` or error boundary is required.
268
-
269
- To keep the toolbar out of the production bundle entirely, gate the *import* rather than the props, with a `next/dynamic` import behind the same flag.
270
-
271
- ### Tracking Pixel
272
-
273
- The tracking pixel collects the interaction data that powers personalization and A/B testing. Include it in **all** environments, preview and production alike.
274
-
275
- ```tsx
276
- import { extractAccessToken, PreprTrackingPixel } from '@preprio/toolkit/nextjs';
277
-
278
- export default function RootLayout({ children }: { children: ReactNode }) {
279
- const accessToken = extractAccessToken(process.env.PREPR_GRAPHQL_URL!);
280
-
281
- return (
282
- <html lang="en">
283
- <body>
284
- {children}
285
- {accessToken && <PreprTrackingPixel id={accessToken} />}
286
- </body>
287
- </html>
288
- );
289
- }
290
- ```
291
-
292
- `PreprTrackingPixel` renders nothing and loads the CDN script on mount, so its position in the tree does not matter.
293
-
294
- ### API Integration
295
-
296
- Use `getPreprHeaders()` to forward the request's Prepr personalization context to your Prepr GraphQL fetches. Prepr resolves segments and A/B variants from these headers, so a query sent without them always returns the default, unpersonalized content.
297
-
298
- The headers are set by the middleware, so they are only present on routes its matcher covers — off-matcher requests yield an empty object.
299
-
300
- The signature differs per framework: `getPreprHeaders()` is async and zero-argument on Next.js (it reads `headers()` for you), while the Astro, SvelteKit, and Nuxt builds are synchronous and take the request's `Headers`.
301
-
302
- #### With the Fetch API
303
-
304
- ```typescript
305
- import { getPreprHeaders } from '@preprio/toolkit/nextjs';
306
-
307
- async function getPage(slug: string) {
308
- const response = await fetch(process.env.PREPR_GRAPHQL_URL!, {
309
- method: 'POST',
310
- headers: {
311
- 'Content-Type': 'application/json',
312
- ...(await getPreprHeaders()),
313
- },
314
- body: JSON.stringify({
315
- query: `
316
- query GetPageBySlug($slug: String!) {
317
- Page(slug: $slug) {
318
- title
319
- content
320
- }
321
- }
322
- `,
323
- variables: { slug },
324
- }),
325
- cache: 'no-store',
326
- });
327
-
328
- return response.json();
329
- }
330
- ```
331
-
332
- #### With Apollo Client
191
+ ### With Apollo Client
333
192
 
334
193
  ```typescript
335
194
  import { getClient } from '@/lib/client';
@@ -348,7 +207,7 @@ async function getPage(slug: string) {
348
207
  }
349
208
  ```
350
209
 
351
- ### Image Loader
210
+ ### Image loader
352
211
 
353
212
  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`.
354
213
 
@@ -375,16 +234,12 @@ The API emits each asset's real dimensions, so a Prepr URL renders as-is:
375
234
  https://393qibegr87z.b-cdn.net/w_800,h_600,fx_75,fy_67/b7ijwafd586-photo.jpg
376
235
  ```
377
236
 
378
- The loader rewrites `w_` to the width Next asks for and scales `h_` alongside it, holding the emitted ratio — for a cropped asset that ratio is the authored crop box, so the crop survives the resize. Focal points (`fx`/`fy`) are percentages and carry through unchanged, as does any other option in the segment. Quality is not a CDN option and is ignored.
379
-
380
- URLs with no transform segment pass through untouched — set `unoptimized` on those `<Image>`s.
237
+ The loader rewrites `w_` to the width Next asks for and scales `h_` alongside it, holding the emitted ratio — for a cropped asset that ratio is the authored crop box, so the crop survives the resize. Focal points (`fx`/`fy`) are percentages and carry through unchanged, as does any other option in the segment. Quality is not a CDN option and is ignored. URLs with no transform segment pass through untouched — set `unoptimized` on those `<Image>`s.
381
238
 
382
239
  See the runnable example at `examples/nextjs` for the full source of truth.
383
240
 
384
241
  ## Astro
385
242
 
386
- ### Middleware Setup
387
-
388
243
  **`src/middleware.ts`**
389
244
 
390
245
  ```typescript
@@ -392,11 +247,11 @@ import { onPreprRequest } from '@preprio/toolkit/astro';
392
247
  import { defineMiddleware } from 'astro:middleware';
393
248
 
394
249
  export const onRequest = defineMiddleware((context, next) =>
395
- onPreprRequest(context, next, { preview: import.meta.env.DEV })
250
+ onPreprRequest(context, next, { preview: import.meta.env.DEV }),
396
251
  );
397
252
  ```
398
253
 
399
- ### Layout Integration
254
+ **Layout** — the same preview flag gates `getToolbarProps`:
400
255
 
401
256
  ```astro
402
257
  ---
@@ -404,8 +259,8 @@ import PreprToolbar from '@preprio/toolkit/astro/components/PreprToolbar';
404
259
  import PreprTrackingPixel from '@preprio/toolkit/astro/components/PreprTrackingPixel';
405
260
  import { extractAccessToken, getToolbarProps } from '@preprio/toolkit/astro';
406
261
 
407
- const isPreview = import.meta.env.DEV;
408
- const toolbarProps = isPreview
262
+ const preview = import.meta.env.DEV;
263
+ const toolbarProps = preview
409
264
  ? await getToolbarProps(Astro.request.headers, import.meta.env.PREPR_GRAPHQL_URL)
410
265
  : null;
411
266
  const accessToken = extractAccessToken(import.meta.env.PREPR_GRAPHQL_URL);
@@ -420,16 +275,22 @@ const accessToken = extractAccessToken(import.meta.env.PREPR_GRAPHQL_URL);
420
275
  </html>
421
276
  ```
422
277
 
423
- `import.meta.env.DEV` is Astro's own dev flag; swap in whatever signal marks preview in your deployment. The same value should feed both the middleware and `getToolbarProps`.
278
+ **Data fetching** — the Astro helpers are synchronous and take the request's `Headers`:
424
279
 
425
- The `.astro` components ship as source, compiled by your own Astro/Vite pipeline rather than this package's build. They use full-page navigation by default, matching Astro's MPA model — so switching segment or variant re-runs the server-rendered output.
280
+ ```typescript
281
+ import { getPreprHeaders } from '@preprio/toolkit/astro';
282
+
283
+ const headers = getPreprHeaders(Astro.request.headers);
284
+ ```
285
+
286
+ `import.meta.env.DEV` is Astro's own dev flag; swap in whatever signal marks preview in your deployment.
287
+
288
+ 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.
426
289
 
427
290
  See the runnable example at `examples/astro` for the full source of truth.
428
291
 
429
292
  ## SvelteKit
430
293
 
431
- `svelte` is an optional peer dependency, needed only if you import the `.svelte` components.
432
-
433
294
  **`src/hooks.server.ts`**
434
295
 
435
296
  ```typescript
@@ -439,7 +300,7 @@ import { preprHandle } from '@preprio/toolkit/sveltekit';
439
300
  export const handle = preprHandle({ preview: dev });
440
301
  ```
441
302
 
442
- **`src/routes/+layout.server.ts`** — gate `getToolbarProps` on the same flag:
303
+ **`src/routes/+layout.server.ts`** — the same flag gates `getToolbarProps`:
443
304
 
444
305
  ```typescript
445
306
  import { dev } from '$app/environment';
@@ -450,7 +311,10 @@ import type { LayoutServerLoad } from './$types';
450
311
  export const load: LayoutServerLoad = async ({ request }) => {
451
312
  if (!dev) return { toolbarProps: null };
452
313
 
453
- const toolbarProps = await getToolbarProps(request.headers, env.PREPR_GRAPHQL_URL!);
314
+ const toolbarProps = await getToolbarProps(
315
+ request.headers,
316
+ env.PREPR_GRAPHQL_URL!,
317
+ );
454
318
  return { toolbarProps };
455
319
  };
456
320
  ```
@@ -473,9 +337,7 @@ export const load: LayoutServerLoad = async ({ request }) => {
473
337
  {/if}
474
338
  ```
475
339
 
476
- 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.
477
-
478
- Inside `load` functions and endpoints you can also read the headers the hook already computed straight off `event.locals`:
340
+ **Data fetching** — in `load` functions and endpoints, read the headers the hook already computed off `event.locals`:
479
341
 
480
342
  ```typescript
481
343
  import { getPreprHeadersFromLocals } from '@preprio/toolkit/sveltekit';
@@ -483,18 +345,20 @@ import { getPreprHeadersFromLocals } from '@preprio/toolkit/sveltekit';
483
345
  const preprHeaders = getPreprHeadersFromLocals(event.locals);
484
346
  ```
485
347
 
348
+ 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.
349
+
486
350
  See the runnable example at `examples/sveltekit` for the full source of truth.
487
351
 
488
352
  ## Nuxt
489
353
 
490
- `vue` is an optional peer dependency, needed only if you import the `.vue` components. Requires Nitro's Node.js preset (the Nuxt default).
354
+ Requires Nitro's Node.js preset (the Nuxt default).
491
355
 
492
356
  **`server/middleware/prepr.ts`**
493
357
 
494
358
  ```typescript
495
359
  import { handlePreprRequest } from '@preprio/toolkit/nuxt';
496
360
 
497
- export default defineEventHandler(event => {
361
+ export default defineEventHandler((event) => {
498
362
  handlePreprRequest(event, { preview: import.meta.dev });
499
363
  });
500
364
  ```
@@ -512,7 +376,7 @@ const { data: toolbarProps } = await useAsyncData('prepr-toolbar', async () => {
512
376
  if (!import.meta.dev) return null;
513
377
  return getToolbarProps(
514
378
  new Headers(requestHeaders as Record<string, string>),
515
- useRuntimeConfig().public.preprGraphqlUrl
379
+ useRuntimeConfig().public.preprGraphqlUrl,
516
380
  );
517
381
  });
518
382
  </script>
@@ -524,9 +388,7 @@ const { data: toolbarProps } = await useAsyncData('prepr-toolbar', async () => {
524
388
  </template>
525
389
  ```
526
390
 
527
- 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.
528
-
529
- The middleware folds the computed Prepr headers back onto the incoming request — read them via `useRequestHeaders()` in composables, or straight off the h3 event in server routes:
391
+ **Data fetching** — the middleware folds the computed Prepr headers back onto the incoming request. Read them via `useRequestHeaders()` in composables, or straight off the h3 event in server routes:
530
392
 
531
393
  ```typescript
532
394
  import { getPreprHeadersFromEvent } from '@preprio/toolkit/nuxt';
@@ -534,6 +396,8 @@ import { getPreprHeadersFromEvent } from '@preprio/toolkit/nuxt';
534
396
  const preprHeaders = getPreprHeadersFromEvent(event);
535
397
  ```
536
398
 
399
+ 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.
400
+
537
401
  See the runnable example at `examples/nuxt` for the full source of truth.
538
402
 
539
403
  ## React (no framework)
@@ -558,17 +422,17 @@ Mount `PreprPreview` once per page — two copies start two editor bridges.
558
422
 
559
423
  ### Without personalization
560
424
 
561
- A client-rendered app with no segments and no A/B testing needs **no middleware and no server helpers at all**. The middleware exists to resolve segment and variant cookies into request headers; with both features off there are no headers to resolve. What remains is click-to-edit and the editor's scroll restore, which are pure client concerns:
425
+ A client-rendered app with no segments and no A/B testing needs **no middleware and no server helpers at all**. The middleware exists to resolve segment and variant cookies into request headers; with both features off there are no headers to resolve. What remains — click-to-edit and the editor's scroll restore — is purely client-side:
562
426
 
563
427
  ```tsx
564
428
  <PreprPreview options={{ features: { segments: false, abTesting: false } }} />
565
429
  ```
566
430
 
567
- No `navigation` prop is needed here. The default adapter drives `window.location`, and with both features off the only reachable path is the preview-mode refresh — a full reload either way. `PreprPreview` also needs no router context in this configuration, so it can mount anywhere in the tree.
431
+ No `navigation` prop or router context is needed in this configuration; the component can mount anywhere in the tree.
568
432
 
569
433
  ### With personalization
570
434
 
571
- Segments and A/B testing need request headers, which need a server. A static SPA has none — so this requires either a framework with a server (React Router in framework mode, Next.js) or your own backend using the [vanilla core](#any-other-framework).
435
+ Segments and A/B testing need request headers, which need a server — either a framework with one (React Router in framework mode, Next.js) or your own backend using the [vanilla core](#any-other-framework).
572
436
 
573
437
  Once there is a server, pass a `navigation` adapter so segment and variant switches route softly instead of reloading:
574
438
 
@@ -581,7 +445,7 @@ const location = useLocation();
581
445
  <PreprPreview
582
446
  {...toolbarProps}
583
447
  navigation={{
584
- navigate: url => navigate(url),
448
+ navigate: (url) => navigate(url),
585
449
  currentPath: () => location.pathname + location.search,
586
450
  }}
587
451
  />;
@@ -591,7 +455,7 @@ const location = useLocation();
591
455
 
592
456
  ### Tracking pageviews
593
457
 
594
- `loadTrackingPixel` queues one `pageload` event when it installs. Client-side route changes emit nothing on their own, so a SPA records a single pageview per session unless you fire them yourself:
458
+ `loadTrackingPixel` queues one `pageload` event when it installs. Client-side route changes emit nothing on their own, so fire them yourself:
595
459
 
596
460
  ```tsx
597
461
  import { useEffect } from 'react';
@@ -622,9 +486,12 @@ import { processPreprRequest } from '@preprio/toolkit';
622
486
 
623
487
  export function preprMiddleware({ preview } = {}) {
624
488
  return (req, res, next) => {
625
- const { requestHeaders, responseCookies } = processPreprRequest(toWebRequest(req), {
626
- preview,
627
- });
489
+ const { requestHeaders, responseCookies } = processPreprRequest(
490
+ toWebRequest(req),
491
+ {
492
+ preview,
493
+ },
494
+ );
628
495
 
629
496
  for (const cookie of responseCookies) {
630
497
  res.cookie(cookie.name, cookie.value, {
@@ -639,8 +506,6 @@ export function preprMiddleware({ preview } = {}) {
639
506
  }
640
507
  ```
641
508
 
642
- `processPreprRequest` reads no environment variables — no part of the toolkit does. Resolve the preview flag yourself before passing it in.
643
-
644
509
  Forward the headers on your GraphQL fetch with `getPreprHeadersFromHeaders(requestHeaders)`, and resolve toolbar props with `getToolbarPropsFromHeaders(requestHeaders, graphqlUrl)`.
645
510
 
646
511
  **Client** — mount the toolbar imperatively:
@@ -660,115 +525,49 @@ Serialize the server-computed props into the page — a `<script type="applicati
660
525
 
661
526
  ### Framework server helpers
662
527
 
663
- Each wrapper exposes the same five helpers, differing only in how they reach the request headers.
664
-
665
- | Helper | Next.js | Astro / SvelteKit / Nuxt |
666
- | --- | --- | --- |
667
- | `getPreprUUID` | `await getPreprUUID()` | `getPreprUUID(headers)` |
668
- | `getActiveSegment` | `await getActiveSegment()` | `getActiveSegment(headers)` |
669
- | `getActiveVariant` | `await getActiveVariant()` | `getActiveVariant(headers)` |
670
- | `getPreprHeaders` | `await getPreprHeaders()` | `getPreprHeaders(headers)` |
671
- | `getToolbarProps` | `await getToolbarProps(token)` | `await getToolbarProps(headers, token)` |
672
-
673
- The Next.js versions read via `next/headers` and are async. The Astro, SvelteKit and Nuxt versions take a standard `Headers` (`Astro.request.headers`, `event.request.headers`, or one built from `useRequestHeaders()`) and are sync, except `getToolbarProps`.
674
-
675
- #### `getPreprUUID()`
676
-
677
- Returns the current visitor's Prepr Customer ID.
678
-
679
- ```typescript
680
- const customerId = await getPreprUUID();
681
- // 'uuid-string' or null
682
- ```
528
+ Each wrapper exposes the same five helpers, differing only in how they reach the request headers. The Next.js versions read `next/headers` and are async; the Astro, SvelteKit, and Nuxt versions take a standard `Headers` (`Astro.request.headers`, `event.request.headers`, or one built from `useRequestHeaders()`) and are sync, except `getToolbarProps`.
683
529
 
684
- Returns `null` when the middleware did not run for this request — a useful check when headers seem to be missing.
685
-
686
- #### `getActiveSegment()`
687
-
688
- Returns the currently active segment.
689
-
690
- ```typescript
691
- const segment = await getActiveSegment();
692
- // 'segment-id' or null
693
- ```
694
-
695
- #### `getActiveVariant()`
696
-
697
- Returns the active A/B testing variant.
698
-
699
- ```typescript
700
- const variant = await getActiveVariant();
701
- // 'A' | 'B' | null
702
- ```
703
-
704
- #### `getPreprHeaders()`
705
-
706
- Returns all Prepr headers for the request, ready to spread into a fetch.
707
-
708
- ```typescript
709
- const headers = await getPreprHeaders();
710
- // { 'prepr-customer-id': 'uuid', 'Prepr-Segments': 'segment-id', ... }
711
- ```
530
+ | Helper | Next.js | Astro / SvelteKit / Nuxt | Returns |
531
+ | ------------------ | ------------------------------ | --------------------------------------- | ------------------------------------------------------------------------------------------------------- |
532
+ | `getPreprUUID` | `await getPreprUUID()` | `getPreprUUID(headers)` | Visitor's Prepr Customer ID, or `null` when the middleware did not run for this request. |
533
+ | `getActiveSegment` | `await getActiveSegment()` | `getActiveSegment(headers)` | Active segment ID, or `null`. |
534
+ | `getActiveVariant` | `await getActiveVariant()` | `getActiveVariant(headers)` | `'A'`, `'B'`, or `null`. |
535
+ | `getPreprHeaders` | `await getPreprHeaders()` | `getPreprHeaders(headers)` | All Prepr headers as an object, ready to spread into a fetch. |
536
+ | `getToolbarProps` | `await getToolbarProps(token)` | `await getToolbarProps(headers, token)` | `{ activeSegment, activeVariant, segments }`. Never throws — failures degrade to an empty segment list. |
712
537
 
713
- #### `getToolbarProps(token)`
714
-
715
- Fetches the props needed to mount the toolbar. Never throws.
716
-
717
- ```typescript
718
- const props = await getToolbarProps(process.env.PREPR_GRAPHQL_URL!);
719
- // { activeSegment, activeVariant, segments }
720
- ```
538
+ `getPreprUUID()` returning `null` is a useful check when headers seem to be missing — it means the middleware did not cover the route.
721
539
 
722
540
  ### Token helpers
723
541
 
724
542
  Exported from every entry point, including the core.
725
543
 
726
- #### `validatePreprToken(token)`
727
-
728
- Asserts that a Prepr GraphQL URL is well formed. Returns nothing and **throws** `PreprError` on a bad value — code `MISSING_TOKEN` when empty, `INVALID_TOKEN` when it is not an HTTPS URL.
729
-
730
- ```typescript
731
- validatePreprToken(process.env.PREPR_GRAPHQL_URL!); // throws PreprError if invalid
732
- ```
733
-
734
- #### `extractAccessToken(url)`
735
-
736
- Extracts the access token from a Prepr GraphQL URL — the value `PreprTrackingPixel` needs as its `id`.
737
-
738
- Returns the token as a `string`. It never returns `null` — a URL that is malformed, not on `graphql.prepr.io`, or missing a token segment throws a `PreprError` with code `INVALID_TOKEN`.
544
+ - **`validatePreprToken(token)`** — asserts a Prepr GraphQL URL is well formed. Returns nothing; **throws** `PreprError` on a bad value (`MISSING_TOKEN` when empty, `INVALID_TOKEN` when not an HTTPS URL).
545
+ - **`extractAccessToken(url)`** — extracts the access token from a Prepr GraphQL URL — the value `PreprTrackingPixel` needs as its `id`. Always returns a `string`; a URL that is malformed, not on `graphql.prepr.io`, or missing a token segment throws `PreprError(INVALID_TOKEN)`.
739
546
 
740
547
  ```typescript
741
548
  const token = extractAccessToken('https://graphql.prepr.io/abc123');
742
549
  // 'abc123'
743
-
744
- extractAccessToken('not-a-url'); // throws PreprError(INVALID_TOKEN)
745
550
  ```
746
551
 
747
552
  ### Components
748
553
 
749
554
  #### `PreprToolbar`
750
555
 
751
- The toolbar. Takes the props returned by `getToolbarProps`, and renders nothing itself — the UI is a shadow-DOM custom element mounted imperatively.
556
+ The toolbar. Takes the props returned by `getToolbarProps`, and renders nothing itself — the UI is a shadow-DOM custom element mounted imperatively. There is no provider to wrap your tree in; state lives in the toolbar's own store.
752
557
 
753
558
  ```tsx
754
559
  <PreprToolbar {...toolbarProps} />
755
560
  ```
756
561
 
757
- There is no provider to wrap your tree in; state lives in the toolbar's own store.
758
-
759
562
  #### `PreprPreview`
760
563
 
761
564
  From `@preprio/toolkit/react`. The framework-free equivalent of `PreprToolbar`: same preview runtime, with the router binding left to you. Renders nothing.
762
565
 
763
- ```tsx
764
- <PreprPreview {...toolbarProps} navigation={adapter} />
765
- ```
766
-
767
- | Prop | Type | Description |
768
- | --- | --- | --- |
769
- | `activeSegment` / `activeVariant` / `segments` | — | Toolbar data, as returned by `getToolbarProps`. Optional for a headless preview. |
770
- | `options` | `PreprPreviewOptions` | Feature flags, `ui`, locale, debug (see [Preview Options](#preview-options)). |
771
- | `navigation` | `PreprNavigationAdapter` | Optional router binding. Defaults to `window.location`, which is correct for any router that keeps the URL bar in sync. |
566
+ | Prop | Type | Description |
567
+ | ---------------------------------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------- |
568
+ | `activeSegment` / `activeVariant` / `segments` | — | Toolbar data, as returned by `getToolbarProps`. Optional for a headless preview. |
569
+ | `options` | `PreprPreviewOptions` | Feature flags, `ui`, locale, debug (see [Preview options](#preview-options)). |
570
+ | `navigation` | `PreprNavigationAdapter` | Optional router binding. Defaults to `window.location`, which is correct for any router that keeps the URL bar in sync. |
772
571
 
773
572
  Every prop is read once on mount; changing one afterwards has no effect, since a live update would have to tear down the editor bridge and re-announce the preview. Remount to change configuration.
774
573
 
@@ -777,24 +576,29 @@ Every prop is read once on mount; changing one afterwards has no effect, since a
777
576
  Loads Prepr's CDN tracking pixel on mount. Renders nothing. Exported from both `@preprio/toolkit/react` and `@preprio/toolkit/nextjs` — the same component, with nothing Next-specific about it.
778
577
 
779
578
  ```tsx
780
- <PreprTrackingPixel id={accessToken} config={{ destinations: { googleTagManager: true } }} />
579
+ <PreprTrackingPixel
580
+ id={accessToken}
581
+ config={{ destinations: { googleTagManager: true } }}
582
+ />
781
583
  ```
782
584
 
783
- | Prop | Type | Description |
784
- | --- | --- | --- |
785
- | `id` | `string` | Prepr tracking/access token. Required. |
786
- | `config` | `PreprPixelConfig` | Optional pixel configuration (see [Pixel Options](#pixel-options)). |
585
+ | Prop | Type | Description |
586
+ | -------- | ------------------ | ------------------------------------------------------------------- |
587
+ | `id` | `string` | Prepr tracking/access token. Required. |
588
+ | `config` | `PreprPixelConfig` | Optional pixel configuration (see [Pixel options](#pixel-options)). |
787
589
 
788
590
  ### Core exports
789
591
 
790
- Available from `@preprio/toolkit` for apps outside Next.js, Astro, and SvelteKit.
592
+ Available from `@preprio/toolkit` for apps outside the supported frameworks.
791
593
 
792
594
  #### `processPreprRequest(request, options?)`
793
595
 
794
596
  Computes the Prepr headers and cookies for a WHATWG `Request`.
795
597
 
796
598
  ```typescript
797
- const { requestHeaders, responseCookies } = processPreprRequest(request, { preview: true });
599
+ const { requestHeaders, responseCookies } = processPreprRequest(request, {
600
+ preview: true,
601
+ });
798
602
  // requestHeaders: Headers — forward downstream and on to Prepr
799
603
  // responseCookies: CookieSpec[] — { name, value, maxAge, path }
800
604
  ```
@@ -808,7 +612,7 @@ const controller = createPreprPreview({
808
612
  props: toolbarProps,
809
613
  options: { debug: true, locale: 'nl' },
810
614
  navigation: {
811
- navigate: url => router.push(url),
615
+ navigate: (url) => router.push(url),
812
616
  currentPath: () => window.location.pathname + window.location.search,
813
617
  reload: () => router.refresh(),
814
618
  },
@@ -817,52 +621,25 @@ const controller = createPreprPreview({
817
621
  controller.destroy();
818
622
  ```
819
623
 
820
- | Option | Type | Description |
821
- | --- | --- | --- |
822
- | `props` | `PreprToolbarProps` | From `getToolbarProps`. Optional — a headless preview has no segment list to pass. |
823
- | `options` | `PreprPreviewOptions` | `{ debug?, locale?, features?, ui?, allowedEditorOrigins? }`. |
824
- | `navigation` | `PreprNavigationAdapter` | How segment/variant switches navigate. |
825
-
826
- The navigation adapter is optional. Omit it and the toolbar uses `window.location.assign` — a full page load. Supply one to integrate with a client-side router; its `reload` member is optional too, and runs after a preview-mode toggle (defaulting to `window.location.reload()`). The Next.js and SvelteKit wrappers wire all of this up for you.
827
-
828
- `createPreprPreview` is a no-op outside a browser (no `window`/`document`) and returns a controller whose `destroy()` does nothing, so it is safe to call during SSR.
829
-
830
- Chrome is skipped — while every side effect stays wired — when any of these hold: `ui: false`, `?prepr_hide_bar=true` (how the Prepr live-preview iframe suppresses the bar), or running inside the editor's iframe. See [Headless preview](#headless-preview-no-toolbar-ui).
831
-
832
- #### `loadTrackingPixel(id, config?)`
833
-
834
- Installs the CDN tracking pixel. Idempotent, and a no-op outside a browser.
835
-
836
- ```typescript
837
- loadTrackingPixel('YOUR_ACCESS_TOKEN', {
838
- destinations: { googleTagManager: true },
839
- variantImpressionThreshold: 0.5,
840
- });
841
- ```
842
-
843
- This is a typed facade over Prepr's existing CDN pixel (`https://cdn.tracking.prepr.io/js/prepr-v2.min.js`). It reproduces the legacy `<script>` snippet's queue-stub semantics, so calls made before the CDN script finishes loading are queued and flushed once it is ready.
844
-
845
- #### `trackEvent(name, data?)`
624
+ | Option | Type | Description |
625
+ | ------------ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
626
+ | `props` | `PreprToolbarProps` | From `getToolbarProps`. Optional — a headless preview has no segment list to pass. |
627
+ | `options` | `PreprPreviewOptions` | `{ debug?, locale?, features?, ui?, allowedEditorOrigins? }`. See [Preview options](#preview-options). |
628
+ | `navigation` | `PreprNavigationAdapter` | How segment/variant switches navigate. Optional — omitted, the toolbar uses `window.location.assign` (a full page load). `reload` is optional too, runs after a preview-mode toggle, and defaults to `window.location.reload()`. The Next.js and SvelteKit wrappers wire all of this up for you. |
846
629
 
847
- Sends a custom tracking event.
630
+ `createPreprPreview` is a no-op outside a browser (no `window`/`document`) and returns a controller whose `destroy()` does nothing, so it is safe to call during SSR. Call it **once per page** — two calls start two bridges and announce the preview twice.
848
631
 
849
- ```typescript
850
- trackEvent('add_to_cart', { productId: 'abc123' });
851
- ```
852
-
853
- #### `setTrackingParam(key, value)`
854
-
855
- Sets a persistent tracking parameter on the pixel.
632
+ #### Tracking
856
633
 
857
- ```typescript
858
- setTrackingParam('user_type', 'returning');
859
- ```
634
+ - **`loadTrackingPixel(id, config?)`** — installs the CDN tracking pixel. Idempotent, and a no-op outside a browser. A typed facade over Prepr's existing CDN pixel (`https://cdn.tracking.prepr.io/js/prepr-v2.min.js`), reproducing the legacy `<script>` snippet's queue-stub semantics: calls made before the CDN script loads are queued and flushed once it is ready.
635
+ - **`trackEvent(name, data?)`** — sends a custom tracking event: `trackEvent('add_to_cart', { productId: 'abc123' })`.
636
+ - **`setTrackingParam(key, value)`** — sets a persistent tracking parameter: `setTrackingParam('user_type', 'returning')`.
860
637
 
861
638
  `trackEvent` and `setTrackingParam` are independent of `loadTrackingPixel` — they work against `window.prepr` however it got installed, including a legacy HTML snippet already on the page. If no pixel is installed at all they warn once in the console and return without throwing.
862
639
 
863
640
  #### `stegaClean(value)`
864
641
 
865
- Strips stega-encoded characters from a string. Rarely needed, since cleaning is automatic (see [Visual Editing](#visual-editing)), but available when you need a clean value for comparison, sorting, or a non-DOM API.
642
+ Strips stega-encoded characters from a string. Rarely needed, since cleaning is automatic (see [Visual editing](#visual-editing)), but available when you need a clean value for comparison, sorting, or a non-DOM API.
866
643
 
867
644
  #### Header-based server helpers
868
645
 
@@ -879,75 +656,41 @@ import {
879
656
  } from '@preprio/toolkit';
880
657
  ```
881
658
 
882
- Unlike `getToolbarProps`, `getPreprEnvironmentSegments(token)` throws on failure — see [Error Handling](#error-handling).
883
-
884
- ## Configuration Options
885
-
886
- ### Environment Variables
659
+ Unlike `getToolbarProps`, `getPreprEnvironmentSegments(token)` throws on failure — see [Error handling](#error-handling).
887
660
 
888
- The toolkit reads no environment variables itself. The one value you need to store is the GraphQL endpoint, under any name you like:
661
+ ## Configuration
889
662
 
890
- | Variable | Required | Default | Description |
891
- | --- | --- | --- | --- |
892
- | `PREPR_GRAPHQL_URL` | Yes | — | Your Prepr GraphQL endpoint URL. The name is yours to choose; it is passed to `getToolbarProps` as a plain string. |
893
-
894
- ### Middleware Options
663
+ ### Middleware options
895
664
 
896
665
  ```typescript
897
666
  // Simple usage (creates a new NextResponse)
898
- createPreprMiddleware(request, { preview: true });
667
+ createPreprMiddleware(request, { preview });
899
668
 
900
669
  // Chaining usage (folds onto an existing NextResponse)
901
- createPreprMiddleware(request, response, { preview: true });
670
+ createPreprMiddleware(request, response, { preview });
902
671
  ```
903
672
 
904
- | Option | Type | Description |
905
- | --- | --- | --- |
906
- | `preview` | `boolean` | Enables preview mode. The only gate — resolve it from your own environment signal. |
907
- | `features` | `PreprFeatures` | Disable features app-wide. See [Feature flags](#feature-flags). |
908
- | `version` | `string` | Override the version reported in the `Prepr-Package` header. Mainly for tests. |
673
+ | Option | Type | Description |
674
+ | ---------- | --------------- | ---------------------------------------------------------------------------------- |
675
+ | `preview` | `boolean` | Enables preview mode. The only gate — resolve it from your own environment signal. |
676
+ | `features` | `PreprFeatures` | Disable features app-wide. See [Feature flags](#feature-flags). |
677
+ | `version` | `string` | Override the version reported in the `Prepr-Package` header. Mainly for tests. |
909
678
 
910
- ### Preview Options
679
+ ### Preview options
911
680
 
912
681
  ```typescript
913
682
  createPreprPreview({ props, options: { debug: true, locale: 'nl' } });
914
683
  ```
915
684
 
916
- | Option | Type | Default | Description |
917
- | --- | --- | --- | --- |
918
- | `debug` | `boolean` | `false` | Enable debug logging. |
919
- | `locale` | `'en' \| 'nl'` | auto-detected | UI language. Falls back to the first supported browser language, then `en`. |
920
- | `features` | `PreprFeatures` | all enabled | Which features run. See [Feature flags](#feature-flags). |
921
- | `ui` | `boolean` | `true` | Mount the visible toolbar. See [Headless preview](#headless-preview-no-toolbar-ui). |
922
- | `allowedEditorOrigins` | `string[]` | `*.prepr.io` | Exact editor origins allowed to drive this preview, for self-hosted editors. Replaces the wildcard. |
923
-
924
- `features` and `ui` are independent: `features` decides *what runs*, `ui` decides *whether the toolbar is visible*.
925
-
926
- ### Headless preview (no toolbar UI)
685
+ | Option | Type | Default | Description |
686
+ | ---------------------- | --------------- | ------------- | --------------------------------------------------------------------------------------------------- |
687
+ | `debug` | `boolean` | `false` | Enable debug logging. |
688
+ | `locale` | `'en' \| 'nl'` | auto-detected | UI language. Falls back to the first supported browser language, then `en`. |
689
+ | `features` | `PreprFeatures` | all enabled | Which features run. See [Feature flags](#feature-flags). |
690
+ | `ui` | `boolean` | `true` | Mount the visible toolbar. See [Headless preview](#headless-preview-no-toolbar-ui). |
691
+ | `allowedEditorOrigins` | `string[]` | `*.prepr.io` | Exact editor origins allowed to drive this preview, for self-hosted editors. Replaces the wildcard. |
927
692
 
928
- `ui: false` keeps every non-visual side effect wired — click-to-edit, the editor bridge, scroll restore, cookies and headers — while rendering no chrome of its own. That is how a site gets live editing, or editor scroll restore, alongside its own UI instead of the Prepr bar.
929
-
930
- ```typescript
931
- // Live editing, no bar:
932
- const preview = createPreprPreview({
933
- options: { ui: false, features: { editMode: true } },
934
- });
935
-
936
- preview.destroy();
937
- ```
938
-
939
- `props` is optional here — a headless preview that only wants click-to-edit or scroll restore has no segment list to pass. For scroll restore and nothing else:
940
-
941
- ```typescript
942
- createPreprPreview({
943
- options: {
944
- ui: false,
945
- features: { segments: false, abTesting: false, editMode: false },
946
- },
947
- });
948
- ```
949
-
950
- `?prepr_hide_bar=true` and the editor's own iframe both imply `ui: false`. Outside an iframe the bridge is a no-op, so mounting unconditionally is safe. Call `createPreprPreview` **once per page** — two calls start two bridges and announce the preview twice.
693
+ `features` and `ui` are independent: `features` decides _what runs_, `ui` decides _whether the toolbar is visible_.
951
694
 
952
695
  ### Feature flags
953
696
 
@@ -957,38 +700,65 @@ Preview mode is the master switch. Within it, each feature can be turned off wit
957
700
  import type { PreprFeatures } from '@preprio/toolkit';
958
701
 
959
702
  export const preprFeatures: PreprFeatures = {
960
- segments: false, // or { enabled: false }
703
+ segments: false, // or { enabled: false }
961
704
  abTesting: true,
962
705
  editMode: { enabled: false },
963
706
  };
964
707
  ```
965
708
 
966
- | Feature | Off means |
967
- | --- | --- |
968
- | `segments` | No segment picker. No `Prepr-Segments` header, from cookie **or** `?prepr_preview_segment`. No segment cookie written. The `_Segments` API call is skipped, saving a round-trip per page. |
969
- | `abTesting` | No A/B control or variant badge. No `Prepr-ABtesting` header, from cookie **or** `?prepr_preview_ab`. No variant cookie written. |
970
- | `editMode` | No Edit mode control, no click-to-edit overlay, no close-edit pill. |
709
+ | Feature | Off means |
710
+ | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
711
+ | `segments` | No segment picker. No `Prepr-Segments` header, from cookie **or** `?prepr_preview_segment`. No segment cookie written. The `_Segments` API call is skipped, saving a round-trip per page. |
712
+ | `abTesting` | No A/B control or variant badge. No `Prepr-ABtesting` header, from cookie **or** `?prepr_preview_ab`. No variant cookie written. |
713
+ | `editMode` | No Edit mode control, no click-to-edit overlay, no close-edit pill. |
971
714
 
972
715
  Pass the **same object to both sides**. Each enforces its own half, so a feature disabled in only one place is still half-on — the UI would hide a control the middleware still honours:
973
716
 
974
717
  ```typescript
975
718
  // server
976
- createPreprMiddleware(request, { preview: true, features: preprFeatures });
719
+ createPreprMiddleware(request, { preview, features: preprFeatures });
977
720
  const props = await getToolbarProps(graphqlUrl, preprFeatures);
978
721
 
979
722
  // client
980
723
  <PreprToolbar {...props} options={{ features: preprFeatures }} />
981
724
  ```
982
725
 
983
- Every framework's `getToolbarProps` takes `features` as its last argument, and every `<PreprToolbar>` accepts an `options` prop. Keeping the object in one module both sides import is the simplest way to stay in sync.
726
+ Every framework's `getToolbarProps` takes `features` as its last argument, and every `<PreprToolbar>` accepts an `options` prop. Keeping the object in one module both sides import is the simplest way to stay in sync — the examples use `src/prepr-features.ts` (`shared/prepr-features.ts` in Nuxt).
984
727
 
985
728
  Disabling a feature also stops its `segment_changed` / `variant_changed` events, and the Reset button ignores it — state the user cannot see is never rewritten.
986
729
 
987
730
  **`editMode` does not disable the Prepr visual editor.** It governs your site's own click-to-edit affordance. When the Prepr editor frames your site it drives edit mode over its own `postMessage` handshake, which keeps working regardless — that is the CMS operating inside its own iframe, not an affordance offered to your visitors. Treat `editMode: false` as a UI choice, never as a security control.
988
731
 
989
- Feature flags govern the toolbar and the middleware. The toolbar-free scroll-sync entry point carries no personalization state, so there is nothing there to disable.
732
+ ### Headless preview (no toolbar UI)
733
+
734
+ `ui: false` keeps every non-visual side effect wired — click-to-edit, the editor bridge, scroll restore, cookies and headers — while rendering no chrome of its own. That is how a site gets live editing, or editor scroll restore, alongside its own UI instead of the Prepr bar.
735
+
736
+ ```typescript
737
+ // Live editing, no bar:
738
+ const preview = createPreprPreview({
739
+ options: { ui: false, features: { editMode: true } },
740
+ });
741
+
742
+ // Later, on teardown (SPA route change, component unmount):
743
+ preview.destroy();
744
+ ```
745
+
746
+ `props` is optional here — a headless preview that only wants click-to-edit or scroll restore has no segment list to pass. For scroll restore and nothing else:
990
747
 
991
- ### Pixel Options
748
+ ```typescript
749
+ createPreprPreview({
750
+ options: {
751
+ ui: false,
752
+ features: { segments: false, abTesting: false, editMode: false },
753
+ },
754
+ });
755
+ ```
756
+
757
+ Scroll restore comes free with any preview, headless or not: inside the live-preview iframe the editor saves and restores the reader's position over the `postMessage` bridge, with no extra call and no configuration.
758
+
759
+ `?prepr_hide_bar=true` and the editor's own iframe both imply `ui: false`; the bridge stays connected in each case. Outside an iframe the bridge is a no-op, so mounting unconditionally is safe. The handshake is only ever accepted from `https://<tenant>.prepr.io`, or from `allowedEditorOrigins` when set.
760
+
761
+ ### Pixel options
992
762
 
993
763
  ```typescript
994
764
  loadTrackingPixel(id, {
@@ -997,23 +767,23 @@ loadTrackingPixel(id, {
997
767
  });
998
768
  ```
999
769
 
1000
- | Option | Type | Description |
1001
- | --- | --- | --- |
1002
- | `destinations.googleTagManager` | `boolean` | Forward events to Google Tag Manager. |
1003
- | `variantImpressionThreshold` | `number` | Visibility ratio required before an A/B variant impression is counted. |
770
+ | Option | Type | Description |
771
+ | ------------------------------- | --------- | ---------------------------------------------------------------------- |
772
+ | `destinations.googleTagManager` | `boolean` | Forward events to Google Tag Manager. |
773
+ | `variantImpressionThreshold` | `number` | Visibility ratio required before an A/B variant impression is counted. |
1004
774
 
1005
775
  ### Theming
1006
776
 
1007
777
  The toolbar's shadow DOM reads its look from CSS custom properties set on the `prepr-toolbar` host element, or any ancestor — custom properties inherit through the shadow boundary. All have fallbacks, so theming is entirely optional.
1008
778
 
1009
- | Custom property | Purpose | Default |
1010
- | --- | --- | --- |
1011
- | `--prepr-primary` | Toggle button, badges, status pill color | `#4338ca` |
1012
- | `--prepr-bg` | Panel background | `#eef2ff` |
1013
- | `--prepr-text` | Reserved for future text theming | `#1f2937` |
1014
- | `--prepr-radius` | Corner radius (panel, radio group, inputs) | `8px` |
1015
- | `--prepr-shadow` | Panel drop shadow | `0px 0px 40px 0px rgba(31, 41, 55, 0.24)` |
1016
- | `--prepr-z-index` | Base stacking order (backdrop/panel/pills) | `10000` |
779
+ | Custom property | Purpose | Default |
780
+ | ----------------- | ------------------------------------------ | ----------------------------------------- |
781
+ | `--prepr-primary` | Toggle button, badges, status pill color | `#4338ca` |
782
+ | `--prepr-bg` | Panel background | `#eef2ff` |
783
+ | `--prepr-text` | Reserved for future text theming | `#1f2937` |
784
+ | `--prepr-radius` | Corner radius (panel, radio group, inputs) | `8px` |
785
+ | `--prepr-shadow` | Panel drop shadow | `0px 0px 40px 0px rgba(31, 41, 55, 0.24)` |
786
+ | `--prepr-z-index` | Base stacking order (backdrop/panel/pills) | `10000` |
1017
787
 
1018
788
  ```css
1019
789
  prepr-toolbar {
@@ -1057,7 +827,7 @@ The toolbar sets the segment; your data fetch decides what to do with it. Check
1057
827
  - **Server helpers in client components**: the Next.js helpers read `next/headers` and are server-only. Calling them from a `'use client'` component fails at build time.
1058
828
  - **Missing peer dependencies**: importing `/nextjs` without `next`, `react`, and `react-dom` installed will not resolve. The core and `/astro` entry points have no framework peers.
1059
829
 
1060
- ### Error Handling
830
+ ### Error handling
1061
831
 
1062
832
  Fetch and token failures throw `PreprError`, which carries a machine-readable code:
1063
833
 
@@ -1065,7 +835,9 @@ Fetch and token failures throw `PreprError`, which carries a machine-readable co
1065
835
  import { PreprError, getPreprEnvironmentSegments } from '@preprio/toolkit';
1066
836
 
1067
837
  try {
1068
- const segments = await getPreprEnvironmentSegments(process.env.PREPR_GRAPHQL_URL!);
838
+ const segments = await getPreprEnvironmentSegments(
839
+ process.env.PREPR_GRAPHQL_URL!,
840
+ );
1069
841
  } catch (error) {
1070
842
  if (error instanceof PreprError) {
1071
843
  console.log('Error code:', error.code);
@@ -1078,7 +850,7 @@ Codes: `INVALID_TOKEN`, `MISSING_TOKEN`, `HTTP_ERROR`, `FETCH_ERROR`, `INVALID_R
1078
850
 
1079
851
  `getToolbarProps` is the exception — it swallows these and returns an empty segment list, so a bad token cannot crash your app.
1080
852
 
1081
- ### Debug Mode
853
+ ### Debug mode
1082
854
 
1083
855
  ```typescript
1084
856
  createPreprPreview({ props, options: { debug: true } });
@@ -1086,7 +858,7 @@ createPreprPreview({ props, options: { debug: true } });
1086
858
 
1087
859
  ## How It Works
1088
860
 
1089
- ### Middleware Functionality
861
+ ### Middleware
1090
862
 
1091
863
  On each request the middleware:
1092
864
 
@@ -1094,28 +866,22 @@ On each request the middleware:
1094
866
  2. **Tracks UTM parameters** — lifts `utm_*` query params into `Prepr-Context-*` headers.
1095
867
  3. **Manages segments** — resolves the active segment from cookie or preview query param.
1096
868
  4. **Processes A/B tests** — resolves the active variant the same way.
1097
- 5. **Sets headers** — makes all of the above visible to Server Components and route handlers via `headers()`.
869
+ 5. **Sets headers** — makes all of the above visible to your server-side code.
1098
870
 
1099
871
  Cookie and query-parameter names are a frozen wire protocol shared with the Prepr editor.
1100
872
 
1101
- ### Toolbar Features
873
+ ### Toolbar
1102
874
 
1103
875
  - **Segment selection** — preview the site as any configured audience segment.
1104
876
  - **A/B testing** — toggle between variants A and B.
1105
877
  - **Edit mode** — click-to-edit, jumping straight to the field in Prepr.
1106
878
  - **Reset** — clear all personalization overrides.
1107
879
 
1108
- The toolbar renders with Preact into a shadow-DOM custom element (`<prepr-toolbar>`), which is what keeps its styles isolated from the host page in both directions. Segment/variant selection and edit mode are wired up for you; you only mount the toolbar and supply its props.
1109
-
1110
- ### Visual Editing
880
+ The toolbar renders with Preact into a shadow-DOM custom element (`<prepr-toolbar>`), which keeps its styles isolated from the host page in both directions. Segment/variant selection and edit mode are wired up for you; you only mount the toolbar and supply its props.
1111
881
 
1112
- With edit mode enabled, the toolkit:
882
+ ### Visual editing
1113
883
 
1114
- 1. **Scans content** — finds editable text via its stega encoding.
1115
- 2. **Auto-cleans stega** — strips the invisible Unicode characters after load, so they cannot cause layout shifts with `letter-spacing` or text balancing.
1116
- 3. **Highlights elements** — proximity-based highlighting as the cursor moves.
1117
- 4. **Provides overlays** — click-to-edit affordances on editable fields.
1118
- 5. **Syncs with Prepr** — talks to the editor over a `postMessage` bridge when running inside the live-preview iframe.
884
+ With edit mode enabled, the toolkit scans for stega-encoded content, strips the invisible Unicode characters after load so they cannot cause layout shifts, highlights editable elements by cursor proximity, and talks to the Prepr editor over a `postMessage` bridge when running inside the live-preview iframe.
1119
885
 
1120
886
  Stega cleaning is automatic. There is no need to call `vercelStegaSplit` or hand-manage hidden spans — the data attributes the toolkit relies on survive the clean, so edit mode still activates instantly.
1121
887
 
@@ -1123,13 +889,13 @@ Stega cleaning is automatic. There is no need to call `vercelStegaSplit` or hand
1123
889
 
1124
890
  Runnable examples live in the repository root:
1125
891
 
1126
- | Example | What it shows |
1127
- | --- | --- |
1128
- | `examples/nextjs` | App Router, middleware, Apollo, custom image loader |
1129
- | `examples/astro` | Astro middleware and `.astro` components |
1130
- | `examples/sveltekit` | `hooks.server.ts`, `+layout.server.ts`, `.svelte` components |
1131
- | `examples/nuxt` | `server/middleware`, `useAsyncData`, `.vue` components |
1132
- | `examples/express` | Vanilla core — a hand-written adapter for an unsupported framework |
892
+ | Example | What it shows |
893
+ | -------------------- | ------------------------------------------------------------------ |
894
+ | `examples/nextjs` | App Router, middleware, Apollo, custom image loader |
895
+ | `examples/astro` | Astro middleware and `.astro` components |
896
+ | `examples/sveltekit` | `hooks.server.ts`, `+layout.server.ts`, `.svelte` components |
897
+ | `examples/nuxt` | `server/middleware`, `useAsyncData`, `.vue` components |
898
+ | `examples/express` | Vanilla core — a hand-written adapter for an unsupported framework |
1133
899
 
1134
900
  ## Support
1135
901