> For clean Markdown of any page, append .md to the page URL. > For a complete documentation index, see https://contentful.com/developers/docs/llms.txt. > For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://contentful.com/_mcp/server. # Integrate the Optimization Web SDK in a web app > This guide helps you implement client-side personalization and analytics in a browser application, such as a static site, multi-page app, SPA, or custom frontend runtime. ## Overview Use this guide to add Contentful personalization to a browser app you already have that is not built with React — a static site, a multi-page app, a single-page app, or a custom frontend runtime where you want to own the browser SDK lifecycle directly. By the end of the quick start, one piece of content will render its personalized variant in the page once you resolve it, without changing how your app fetches or renders content. **New to personalization?** Here is the whole idea in four points: * In Contentful you author **variants** of an entry and attach them to an **experience** — a rule that decides which visitors see which variant. * As the visitor uses your app, Contentful's **Experience API** looks at who they are and picks the variant for each experience. Swapping a fetched entry for its picked variant is called **resolving** the entry. * Your app hands a Contentful entry to the SDK at the point where that entry becomes output. The SDK gives back the selected variant, or the original entry when no variant applies—the **baseline fallback**. You can fetch the entry yourself or give the SDK your Contentful client and an entry ID; either way, the client stays yours. * You render the returned entry with the same application components you already use. That is enough to start. The guide introduces policy and optional capabilities at the point you need them. You will get there in two milestones: * **Milestone 1 — a personalized entry rendered into the page (the quick start below).** After you emit a page event, fetch an entry, and resolve it, a visitor sees their variant on screen. This is complete and shippable on its own. * **Milestone 2 — live re-personalization (opt-in, later).** Content re-resolves when consent, identity, or profile changes, without a full reload, by subscribing to SDK state and re-rendering. See [State subscriptions, locale changes, and re-rendering](#state-subscriptions-locale-changes-and-re-rendering). This guide uses the `ContentfulOptimization` class from `@contentful/optimization-web`. You create one instance, drive it imperatively — emit events, resolve entries, subscribe to state — and your app keeps ownership of its Contentful client, consent policy, identity, routing, caching, and rendering. The package also ships optional Web Components (`defineContentfulOptimizationElements()`) for a declarative element-based integration; the quick start uses the class, and [Web Components entry rendering](#web-components-entry-rendering) covers the elements. If you are building a React app and want official providers, hooks, components, and router adapters, use the [React Web SDK guide](/personalization/optimization-sdk/integrate-the-react-web-sdk-in-a-react-app/) instead. If your app renders on the server with Next.js, use the [Next.js App Router guide](/personalization/optimization-sdk/integrate-the-optimization-sdk-in-a-nextjs-app-router-app/) or the [Next.js Pages Router guide](/personalization/optimization-sdk/integrate-the-optimization-sdk-in-a-nextjs-pages-router-app/). ## Quick start Most browser + Contentful apps share one shape: you fetch an entry (a page, a hero, a section) and render its fields into the DOM. This quick start assumes that shape and personalizes a single entry. If your app is shaped differently, the change is the same wherever an entry becomes rendered markup; see [Resolving entries and rendering the result](#resolving-entries-and-rendering-the-result). It proves one result: **one entry renders its personalized variant in the page once the SDK resolves it.** This quick start assumes your app may personalize on startup; if personalization must wait for consent, keep this structure and add the [Consent and privacy handoff](#consent-and-privacy-handoff) step before you ship. 1. Install the browser SDK and a Contentful delivery client. Add `contentful` only if your app does not already have a Contentful Delivery API (CDA) client. **Copy this:** ```sh pnpm add @contentful/optimization-web contentful ``` 2. Create one SDK instance for the page or single-page app (SPA) runtime, emit one `page()` event, fetch one single-locale entry, resolve it, and render the result into the DOM. Read the placeholder config from whatever mechanism your build uses to expose browser-visible values, and keep it consistent with the Contentful variables your app already ships. `defaults: { consent: true }` tells the SDK it may personalize and send events for this visitor; the quick start uses always-on consent to keep the path simple — production gates this on the visitor's real choice (see [Consent and privacy handoff](#consent-and-privacy-handoff)). Before you resolve, call `page()` once: a page event asks the Experience API to evaluate the visitor. Its `accepted` field only says whether the SDK allowed the event. Returned `data`, when present, carries current selections. On this new instance, an accepted result without `data` leaves selections empty, so resolution safely returns the baseline; `accepted: false` stops the example before rendering. **Adapt this to your use case:** replace the placeholder values and the `#hero` selector with your own; the config keys are explained in [How the SDK fits your app](#how-the-sdk-fits-your-app). The render step is a minimal placeholder — substitute your own field rendering, template, or DOM update. ```ts import * as contentful from 'contentful' import ContentfulOptimization from '@contentful/optimization-web' const APP_LOCALE = 'en-US' // the one locale you also pass to Contentful const contentfulClient = contentful.createClient({ accessToken: 'your-contentful-delivery-token', environment: 'master', space: 'your-space-id', }) const optimization = new ContentfulOptimization({ spaceId: 'your-space-id', environment: 'master', locale: APP_LOCALE, // consent: allowed to personalize and send events for this visitor. // Use default-on consent only when application policy permits it. defaults: { consent: true }, app: { name: 'my-web-app', version: '1.0.0' }, }) // Emit the page event first; returned data, when present, supplies current selections. const pageResult = await optimization.page() if (!pageResult.accepted) { throw new Error('Optimization page event was blocked; check consent policy') } const baselineEntry = await contentfulClient.getEntry('4ib0hsHWoSOnCVdDkizE8d', { include: 10, // resolve linked experience and variant entries before rendering locale: APP_LOCALE, // one concrete locale — never withAllLocales / locale=* }) // Uses current selections; with no page data or usable variant, this returns baselineEntry. const { entry, isEmptyVariant } = optimization.resolveOptimizedEntry(baselineEntry) const hero = document.querySelector('#hero') if (hero) hero.textContent = isEmptyVariant ? '' : String(entry.fields.headline ?? '') ``` 3. Check that it works. In Contentful, author a variant on the entry you fetch above and attach it to an experience — for a first test, target **all visitors** so you match it automatically. Load the page: the hero renders the variant's text. If the baseline text stays on screen instead, work through [Troubleshooting](#troubleshooting). You now have personalization working. **The rest of this guide is not a re-run of the quick start** — it explains what each step did and covers what the quick start deliberately skipped: real, consent-gated startup; the create-emit-resolve lifecycle; your Contentful fetch requirements and the baseline-fallback contract; page and route events; state subscriptions and live re-rendering; interaction tracking; identity; Web Components; and production hardening. Read straight through, or jump to the section you need. ## Before you start The sections below walk the integration in order. First, gather the few things you can only get from outside this guide: * **A browser app** with a build or runtime that can load an npm package, and its own Contentful fetching already working. `contentful` is a companion dependency you install alongside the SDK if you do not already have a Delivery API client. * **Contentful delivery credentials** — space ID, delivery token, and environment. * **At least one entry with a variant attached to an experience**, authored in Contentful. Without an authored variant, the integration can still run correctly while returning the baseline, so you cannot yet distinguish working personalization from a content-authoring gap. For the first personalized-content test, target all visitors so the test request or visitor matches automatically. * **Your Contentful space values** — space ID and environment, from your Contentful space settings. Find them in the Contentful web app under **Apps → Installed apps → Contentful Personalization → SDK keys**. The Experience and Insights API base URLs default correctly; you only set them for mocks or non-default hosts (see [How the SDK fits your app](#how-the-sdk-fits-your-app)). You do not need a setup inventory up front. Everything else — consent, page events, state subscriptions, tracking, identity — is introduced by the section that needs it. > **Info** > > The Web SDK is bundler-agnostic. Read its config from whatever mechanism your build uses to expose > browser-visible values (a bundler define, `import.meta.env`, a server-injected global, or plain > constants), and keep it consistent with your other browser-visible Contentful variables. Ship only > the Contentful **delivery** token to the browser, never a Management API token. ## Core integration ### How the SDK fits your app **Integration category:** Required for first integration This section explains the `ContentfulOptimization` instance you created in the quick start — what each config key does and how to make startup depend on real consent. The Web SDK is a thin, stateful layer between three things you already have or control: your Contentful data, Contentful's Experience API, and your rendering code. You create one instance and reuse it across route handlers, render code, and interaction handlers. It is not a Contentful client replacement: the Contentful client and credentials are yours, along with routing, rendering, consent policy, identity policy, and cache policy. The config you pass to `new ContentfulOptimization(...)` breaks down like this: 1. `spaceId` and `environment` identify your Contentful space and environment. Read them from browser-safe config. 2. `locale` is the one locale the SDK uses for Experience and event context. Use the same locale you pass to Contentful. 3. `api` overrides the Experience and Insights endpoints (`experienceBaseUrl`, `insightsBaseUrl`). Set these only for a mock, a proxy, or non-default hosts; omit them otherwise. 4. `defaults` is the SDK's starting state: `consent` (may personalize and send events) and `persistenceConsent` (may store the profile-id cookie — the anonymous identifier the SDK assigns each visitor to keep their variant assignments consistent across visits). If you set `consent` but omit `persistenceConsent`, `persistenceConsent` defaults to your `consent` value. 5. `app` is your app's name and version, sent as event metadata. 6. `logLevel`, `allowedEventTypes`, `autoTrackEntryInteraction`, `cookie`, `queuePolicy`, and `onEventBlocked` are optional and covered in their own sections below. Keep the instance in a module-level binding or another singleton container. In a browser the constructor attaches the instance to `window.contentfulOptimization` and **throws `ContentfulOptimization is already initialized`** if one already exists there. Call `destroy()` only for explicit teardown paths such as tests, hot reload, or a framework root unmount that owns the instance. The quick start used always-on `defaults` to get you a result. For production, make startup depend on real consent: leave `consent` unset (or seed it off) and call `consent(true)` from the UI that owns the visitor's decision, as shown in [Consent and privacy handoff](#consent-and-privacy-handoff). **Adapt this to your use case:** the shared module a real app imports everywhere, with app metadata and API overrides. ```ts import * as contentful from 'contentful' import ContentfulOptimization from '@contentful/optimization-web' const APP_LOCALE = 'en-US' export const contentfulClient = contentful.createClient({ accessToken: 'your-contentful-delivery-token', environment: 'master', space: 'your-space-id', }) // Reuse this singleton across route, render, and tracking handlers. export const optimization = new ContentfulOptimization({ spaceId: 'your-space-id', environment: 'master', locale: APP_LOCALE, app: { name: 'my-web-app', version: '1.0.0' }, // Set these only for mocks or non-default hosts; both default correctly otherwise. api: { experienceBaseUrl: 'https://experience.example.com/', insightsBaseUrl: 'https://insights.example.com/', }, logLevel: 'warn', }) ``` For the locale model, see [Locale handling in the Optimization SDK Suite](/personalization/optimization-sdk/locale-handling-in-the-optimization-sdk-suite/). ### The SDK lifecycle: create, emit, resolve **Integration category:** Required for first integration This is the concept that has no equivalent in the component-based guides, so it is worth stating plainly. The Web SDK is imperative and stateful, and its state fills in a specific order: * **The instance is ready synchronously.** `resolveOptimizedEntry()`, `getFlag()`, and the `states.*` observables (explained in [State subscriptions, locale changes, and re-rendering](#state-subscriptions-locale-changes-and-re-rendering)) work the moment you call `new ContentfulOptimization(...)`. * **But optimization state is empty until an accepted event returns it.** The SDK only has current `selectedOptimizations` after an accepted `page()` or `identify()` call resolves (`identify()` works the same way — see [Identity, profile, and reset](#identity-profile-and-reset)). Resolve an entry before that and you get the baseline — which is correct, just not personalized yet. So the order that matters is: construct → emit `page()` (or `identify()`) → resolve entries. That is why the quick start awaits `page()` before calling `resolveOptimizedEntry()`. 1. Construct the instance once and reuse it (see [How the SDK fits your app](#how-the-sdk-fits-your-app)). 2. Emit an accepted `page()` or `identify()` before rendering optimized content so SDK state carries current `selectedOptimizations`. 3. Resolve and render entries. When you omit the second argument, `resolveOptimizedEntry()` uses the SDK's current state, so later re-renders pick up the latest selections automatically. **Follow this pattern:** the ordered startup sequence. ```ts // 1. The instance is usable immediately after construction. const optimization = new ContentfulOptimization({ spaceId, environment, locale }) // 2. Emit an accepted event so SDK state has selections. `{ accepted: false }` means a guard blocked it. const { accepted } = await optimization.page() // 3. Now resolve against current state. Before step 2, this would return the baseline. if (accepted) renderVisibleEntries() ``` ### Fetching Contentful entries **Integration category:** Required for first integration The Contentful client is yours. This is the boundary, and it has two supported shapes: **you fetch, the SDK resolves**, or **you hand the SDK your client and it fetches by ID or content type and slug for you.** Both end at the same resolution step, and you can use different paths for different entries in the same app. * **Manual** — you fetch the entry with your own client and pass it in. The quick start uses this path. Keep your existing client, fetchers, and caching; the SDK only needs entries to arrive in a shape it can resolve. * **Managed** — you give the SDK your Contentful client once through the `contentful` config key, then identify an entry by ID or `{ contentType, slug, slugField?, entryQuery? }`. `slugField` defaults to `slug`; `entryQuery` carries that source object's CDA query. The client stays yours. The SDK uses `getEntry()` for a single ID and `getEntries()` for slug lookup and eligible ID batches. Either way, the same fetch requirements hold: 1. Fetch with one concrete Contentful locale. Do not use `withAllLocales` or raw Contentful Delivery API (CDA) `locale=*` — all-locale payloads use locale-keyed field maps the resolver cannot read, so entries fall back to baseline. 2. Use an `include` depth deep enough to resolve the whole tree — the entry, its sections, and the linked variant entries. `include: 10` is the common setting. 3. Use the same locale for Contentful and for the SDK so localized Experience responses and rendered content line up. A single-locale entry exposes its optimization fields directly, such as `fields.nt_experiences` and `fields.nt_variants` (the `nt_` prefix is how personalization links appear on an entry). **Adapt this to your use case:** the manual path — your own fetcher, which the render step then resolves. `fetchEntry` is a helper you own and name. ```ts import * as contentful from 'contentful' const APP_LOCALE = 'en-US' const INCLUDE_DEPTH = 10 const contentfulClient = contentful.createClient({ accessToken: 'your-contentful-delivery-token', environment: 'master', space: 'your-space-id', }) export async function fetchEntry(entryId: string) { return await contentfulClient.getEntry(entryId, { include: INCLUDE_DEPTH, // resolve linked experience and variant entries before rendering locale: APP_LOCALE, // keep this aligned with the SDK locale }) } ``` For the managed path, pass your client to the SDK as `contentful: { client }`. The SDK merges your `contentful.defaultQuery`, `entryQuery` (or the separate query argument on the ID overload), the SDK locale as a fallback, and `include: 10`, and caches results per instance (default `{ maxEntries: 100, ttlMs: 300_000 }`; pass `cache: false` to disable, or `clearContentfulEntryCache()` to clear it). `fetchContentfulEntry(id)` returns the fetched entry; the same method accepts a slug-source object. `fetchContentfulEntries(entries)` preserves input order and duplicates. ID sources can batch when their effective locale, include depth, and other query values match; each distinct slug source uses its own `getEntries()` request. `prefetchManagedEntries(entries)` returns server handoff objects for framework adapters. A slug handoff nests the normalized descriptor under `managedEntry` and retains the fetched entry's `sys.id` as `entryId`. `fetchOptimizedEntry(...)` fetches and resolves in one call (see [Resolving entries and rendering the result](#resolving-entries-and-rendering-the-result)). Slug lookup enforces `content_type`, `fields.`, and `limit: 2` after merging the normal managed query, so those selectors win over conflicting query values. The placeholders below are replaced with the source's actual content type, effective slug field, and slug: * No match: `Contentful entry not found for content type "" where "fields." equals "".` * More than one match: `Multiple Contentful entries found for content type "" where "fields." equals "".` The SDK uses the fetched entry's real `sys.id` for handoff identity, resolution metadata, and interaction tracking; it does not treat the slug as an entry ID. **Adapt this to your use case:** the managed path — configure the client once, then fetch by your app-owned content type and slug. ```ts import * as contentful from 'contentful' import ContentfulOptimization from '@contentful/optimization-web' const contentfulClient = contentful.createClient({ accessToken: 'your-contentful-delivery-token', environment: 'master', space: 'your-space-id', }) const optimization = new ContentfulOptimization({ spaceId: 'your-space-id', locale: 'en-US', // Hand the SDK your client; slug lookup calls getEntries() through it. The client stays yours. contentful: { client: contentfulClient }, }) const baselineEntry = await optimization.fetchContentfulEntry({ contentType: 'page', slug: 'home', // slugField defaults to 'slug'; set it only when your content model uses another field. entryQuery: { locale: 'en-US' }, }) ``` For the combined fetch-and-resolve call — `fetchOptimizedEntry(id)`, which fetches and resolves in one step — see the next section. For the resolver contract, see [Entry personalization and variant resolution](/personalization/optimization-sdk/entry-personalization-and-variant-resolution/#single-locale-cda-entry-contract). ### Resolving entries and rendering the result **Integration category:** Required for first integration The quick start showed the resolve-and-render. This explains the return shape and the two things about it that matter everywhere. The rule never changes: **wherever a Contentful entry becomes rendered markup, resolve it first. Render no content when `isEmptyVariant` is `true`; otherwise, render the returned `entry`.** `resolveOptimizedEntry(baselineEntry, selectedOptimizations?)` returns an object: * `entry` — the resolved variant when one applies, or the baseline entry otherwise. This is what you render. * `selectedOptimization` — selection metadata (`experienceId`, `variantIndex`, `sticky`, `variants`). It is `undefined` only when no experience matched (no selections, the entry is not optimized, or no selection matched it). When an experience matches but assigns the visitor to the control/baseline variant, it is defined with `variantIndex: 0` — and the returned `entry` still equals the baseline. So do not read `selectedOptimization === undefined` as "the visitor is seeing baseline content." * `optimizationContextId` — an opaque id you attach to the rendered element so interaction tracking can tie events back to this selection (see [Entry interaction tracking](#entry-interaction-tracking)). * `isEmptyVariant` — `true` when the selected variant has an empty ID. The returned `entry` retains the baseline for tracking context, but your app must render no content. Omit the second argument to resolve against the SDK's current state (the selections from the most recent accepted `page()`/`identify()`); pass an explicit `SelectedOptimizationArray` only when you resolve against selections you captured yourself — for example selections handed over from a server-rendered response (see [Hybrid Node SSR and browser continuity](#hybrid-node-ssr-and-browser-continuity)). If you configured the managed path (`contentful: { client }`), `fetchOptimizedEntry(id, options?)` or `fetchOptimizedEntry({ contentType, slug, slugField?, entryQuery? }, options?)` fetches and resolves in one call and returns the same fields plus the `baselineEntry` it fetched. The slug source object puts the query inside `entryQuery`; its options contain only `selectedOptimizations`. Use a managed call when you want the SDK to own the fetch; use `resolveOptimizedEntry(entry)` when you fetch the entry yourself. **Follow this pattern:** managed fetch-and-resolve in one call. ```ts // The optional second argument is FetchOptimizedEntryOptions: { query?, selectedOptimizations? }. // Omit selectedOptimizations to use current SDK state. const { entry, baselineEntry, selectedOptimization } = await optimization.fetchOptimizedEntry('4ib0hsHWoSOnCVdDkizE8d') ``` The second resolver argument is always `selectedOptimizations`. Omit it to use the stateful SDK's current selections, or pass a captured array positionally. A Contentful entry skeleton is the TypeScript type that declares an entry's content-type ID and fields. When the baseline and variants can use different known content types, put all of their skeletons in one union, pass it as the first type argument, and narrow the result before reading fields. The resolver names this skeleton set `S`. **Follow this pattern:** one skeleton union for the baseline and every possible variant. ```ts import { isEntryOfContentType, type SelectedOptimizationArray, } from '@contentful/optimization-web/api-schemas' import type { ChainModifiers, Entry, EntryFieldTypes, EntrySkeletonType } from 'contentful' type PageSkeleton = EntrySkeletonType<{ title: EntryFieldTypes.Symbol }, 'page'> type HeroSkeleton = EntrySkeletonType<{ headline: EntryFieldTypes.Symbol }, 'hero'> type CtaSkeleton = EntrySkeletonType<{ label: EntryFieldTypes.Symbol }, 'cta'> type PossiblePageSkeleton = PageSkeleton | HeroSkeleton | CtaSkeleton type AppLocale = 'en-US' function renderPage( baselineEntry: Entry, selectedOptimizations?: SelectedOptimizationArray, ): string { const { entry, isEmptyVariant } = optimization.resolveOptimizedEntry< PossiblePageSkeleton, ChainModifiers, AppLocale >(baselineEntry, selectedOptimizations) if (isEmptyVariant) return '' if (isEntryOfContentType(entry, 'hero')) { return String(entry.fields.headline ?? '') } if (isEntryOfContentType(entry, 'cta')) { return String(entry.fields.label ?? '') } return String(entry.fields.title ?? '') } ``` When every variant uses `PageSkeleton`, omit the generic arguments and TypeScript infers that single skeleton from the baseline entry. For an open-ended content model, use `EntrySkeletonType` for `S`; this avoids maintaining a closed union, but fields are unchecked and must be validated before rendering. See [TypeScript content-model choices](/personalization/optimization-sdk/entry-personalization-and-variant-resolution/#typescript-content-model-choices) for the complete modeling trade-offs. A different content type does not by itself trigger baseline fallback. The returned `entry` also remains the baseline for a control selection (`variantIndex === 0`) and an empty variant (`id === ''`); `isEmptyVariant: true` distinguishes the empty variant, which renders no content. Resolution can also fall back to the baseline when no matching selection or usable variant exists, the required links are unresolved or invalid, or the payload uses all locales. This is why the quick start renders default content before you author a variant or before an accepted event supplies selections. Keep the baseline entry id separate from the resolved entry id in the DOM. Later re-renders read the baseline id to resolve again, so overwriting it with the variant id would make the SDK treat a variant as the baseline. **Adapt this to your use case:** a render function that resolves one manually fetched entry and writes it plus its tracking metadata into an element. ```ts async function renderEntry(entryId: string, element: HTMLElement): Promise { const baselineEntry = await fetchEntry(entryId) // your own fetcher from the section above // Omit selections to use current SDK state from the most recent accepted page()/identify(). const { entry, isEmptyVariant, optimizationContextId, selectedOptimization } = optimization.resolveOptimizedEntry(baselineEntry) element.textContent = isEmptyVariant ? '' : String(entry.fields.headline ?? '') // Keep the baseline id separate so re-renders resolve from the baseline, not the variant. element.dataset.ctflBaselineId = baselineEntry.sys.id element.dataset.ctflEntryId = entry.sys.id // the resolved id — used for interaction tracking if (optimizationContextId) element.dataset.ctflOptimizationContextId = optimizationContextId if (selectedOptimization) { element.dataset.ctflOptimizationId = selectedOptimization.experienceId element.dataset.ctflVariantIndex = String(selectedOptimization.variantIndex) } } ``` ### Page and route events **Integration category:** Required for first integration A **page event** signals that a page or route was viewed. The Experience API uses page events to evaluate route-based experiences and to return current selections, so most integrations emit one on first load and on every route change. 1. Call `page()` after SDK initialization for a multi-page app or the first SPA route. It returns `{ accepted, data }`; `{ accepted: false }` means consent or an SDK guard blocked the event. 2. In SPAs, use `trackCurrentPage({ routeKey, buildPayload })` on route changes. It deduplicates consecutive identical route keys (a manual `page()` always emits when consent permits it). 3. Include stable page properties — url, path, search, referrer, title — when your router or analytics taxonomy needs them. 4. In hybrid apps where the server already emitted the first page event, pass `initialPageEvent: 'skip'` to `trackCurrentPage` for the first browser route so the browser does not report a duplicate (see [Hybrid Node SSR and browser continuity](#hybrid-node-ssr-and-browser-continuity)). Both `page()` and the page event emitted by `trackCurrentPage()` inherit the Web SDK's default page context, whose URL is the current browser URL unless page input overrides it. The SDK writes explicit or automatically inferred campaign data to the emitted SDK output `event.context.campaign`, using this precedence, highest first: 1. **Application input: `campaign` at the top level of the `page()` payload or `buildPayload` result.** Any explicit object is the whole campaign source. `campaign: {}` suppresses URL inference. 2. **Application input: `properties.url` in that page payload.** If this URL contains even one supported UTM parameter, the SDK infers the campaign only from this URL. It does not backfill missing fields from the resolved `page.url`. 3. **Resolved top-level `page.url`.** An explicit top-level `page.url` supplies this value; otherwise, the Web default page context supplies the current browser URL. The SDK uses the resolved value only when top-level `campaign` is absent and `properties.url` has no supported UTM parameter. Referrer remains page metadata, but the SDK never reads `page.referrer` or `properties.referrer` for campaign attribution. See [Campaign attribution in event context](/personalization/optimization-sdk/core-state-management/#campaign-attribution-in-event-context) for the supported parameters and full precedence rules. **Copy this:** ```ts const result = await optimization.page() ``` **Adapt this to your use case:** an SPA route tracker with stable route keys, wired to your router. ```ts function getRouteKey(): string { return `${window.location.pathname}${window.location.search}` } async function trackRoute(): Promise { await optimization.trackCurrentPage({ routeKey: getRouteKey(), // stable route keys prevent duplicate SPA page events buildPayload: () => { const url = new URL(window.location.href) return { properties: { path: url.pathname, referrer: document.referrer, search: url.search, title: document.title, url: url.toString(), }, } }, }) } void trackRoute() router.onRouteChange(() => void trackRoute()) // replace with your framework/router hook ``` ### Consent and privacy handoff **Integration category:** Common but policy-dependent Consent policy belongs to your application. The SDK tracks two independent axes: **consent** (may personalize and send events) and **persistenceConsent** (may store the profile-id cookie). While event consent is `undefined` or `false`, the SDK's default allow-list permits only `identify` and `page`; other events stay blocked. 1. If policy permits personalization by default and you render no consent UI, seed accepted consent in `defaults` (as the quick start does). 2. If policy depends on user choice, leave `consent` unset and call `consent(true | false)` from the banner, consent-management platform (CMP) callback, or settings screen that owns the decision. 3. For strict opt-in, pass `allowedEventTypes: []` so no event can emit before an explicit choice. 4. Use object-form consent — `consent({ events: true, persistence: false })` — only when events are permitted but durable profile continuity must stay session-only. A boolean sets both axes together. 5. Persist the visitor's choice in your own store (a cookie, `localStorage`, or account preference) so your UI can restore it next visit. That consent record is **yours** — you name, write, and read it. The SDK does not manage it; it only reflects what you pass to `consent()`. **Follow this pattern:** default-on, when policy permits. ```ts const optimization = new ContentfulOptimization({ spaceId: 'your-space-id', // Starts event emission and durable profile continuity immediately. defaults: { consent: true }, }) ``` **Follow this pattern:** strict opt-in — no event emits until the visitor accepts. ```ts const optimization = new ContentfulOptimization({ spaceId: 'your-space-id', // Replaces the default pre-consent allow-list of identify and page. allowedEventTypes: [], }) ``` **Adapt this to your use case:** a consent control wired to the SDK and to your own consent record. ```ts // This cookie is YOURS: your app writes and reads it. It is not an SDK cookie. const CONSENT_COOKIE = 'app-personalization-consent' function persistConsent(consented: boolean): void { document.cookie = `${CONSENT_COOKIE}=${consented ? 'granted' : 'denied'}; Path=/; SameSite=Lax` } document.querySelector('#consent-accept')?.addEventListener('click', () => { optimization.consent(true) // boolean consent updates both event and persistence consent persistConsent(true) }) document.querySelector('#consent-reject')?.addEventListener('click', () => { optimization.consent(false) // blocks non-allowed events and clears durable profile-continuity storage persistConsent(false) }) ``` The SDK stores its own consent, persistence-consent, and profile-continuity state in `localStorage`; the one persistence value it owns and exposes as a cookie is the browser-readable profile-id cookie `ctfl-opt-aid`. Calling `consent(false)` blocks subsequent non-allowed events and clears SDK-managed durable storage, but it does not erase your app, server, or CMP records, and it does not drop the active in-memory profile — call `reset()` for that (see [Identity, profile, and reset](#identity-profile-and-reset)). For the cross-SDK policy model, see [Consent management in the Optimization SDK Suite](/personalization/optimization-sdk/consent-management-in-the-optimization-sdk-suite/). ### State subscriptions, locale changes, and re-rendering **Integration category:** Common but policy-dependent This is Milestone 2. First render is already complete and shippable; add re-rendering only when some content must re-personalize *after* it first resolves — for example when a visitor accepts consent, signs in, or is identified, and entries should update without a reload. Because the Web SDK is imperative, you get this by subscribing to state and re-running your own render, rather than a framework doing it for you. The SDK exposes its latest accepted profile, selected optimizations, consent state, and diagnostic streams through `states.*`. Every observable emits its current value immediately on subscribe and then emits later updates; read `.current` for a one-off synchronous read. 1. Subscribe to `states.selectedOptimizations` when optimized entries must re-render after `page()`, `identify()`, or a profile change. Re-run the same resolve-and-render you used at first paint. 2. Subscribe to `states.profile` for identity-aware UI, and `states.consent` / `states.persistenceConsent` when a local consent UI must reflect SDK state. 3. Subscribe to `states.eventStream` and `states.blockedEventStream` for diagnostics or approved analytics forwarding (see [Analytics forwarding](#analytics-forwarding)). 4. Unsubscribe when the page root, framework root, or long-lived view tears down. 5. When the app locale changes, call `setLocale(nextLocale)`, then refetch Contentful entries with the new CDA locale and emit a fresh `page()` or `identify()`. `setLocale` updates subsequent Experience API requests and event context only; it does not refetch entries or clear your caches. **Adapt this to your use case:** subscribe once, re-render on selection changes, and clean up. ```ts const subscriptions = [ optimization.states.selectedOptimizations.subscribe((selectedOptimizations) => { if (selectedOptimizations === undefined) return // Re-render after page(), identify(), or a live profile change updates selections. void renderVisibleEntries() }), optimization.states.profile.subscribe((profile) => { const badge = document.querySelector('#profile-id') if (badge) badge.textContent = profile?.id ?? 'anonymous' }), ] window.addEventListener('beforeunload', () => { subscriptions.forEach((subscription) => subscription.unsubscribe()) }) ``` To verify, accept consent or call `identify()`, then confirm your subscribed render swaps the affected entries to their variants without a full reload. ### Entry interaction tracking **Integration category:** Common but policy-dependent Interaction tracking — views, clicks, and hovers on entries — is a browser behavior. The SDK observes any element in the DOM carrying the `data-ctfl-*` tracking attributes, and emits the matching events once consent permits. Automatic tracking for all three interaction types is on by default, so you rarely configure anything to get started. A view interaction session begins when an element reaches the fixed 10% visibility threshold. It qualifies after the element stays at or above that threshold for a continuous 1000 ms dwell. A hover interaction session begins on pointer entry and qualifies after the pointer remains for a continuous 1000 ms dwell. A qualified view interaction session produces two normal event records whose `type` field is `component`: the first when it qualifies and the second, final record when it ends, normally because visibility falls below 10%. Both records carry the same SDK-owned `viewId`. The `viewDurationMs` value is milliseconds measured from the beginning of that view interaction session, when the element reached 10%, so it includes the qualifying dwell. A qualified hover interaction session follows the same two-record pattern with a `type` field of `component_hover`, an SDK-owned `hoverId`, and `hoverDurationMs` measured in milliseconds from pointer entry. Pointer leave or cancel normally ends a hover interaction session. An interaction session that ends before qualification emits no record, and active interaction sessions emit no periodic duration records. When the document becomes hidden, or the page fires `pagehide` or starts unloading, the SDK makes a best-effort attempt to end active qualified interaction sessions. When the page becomes visible again, an element that is still visible starts a fresh view interaction session and must satisfy the 1000 ms dwell again. Hover tracking requires a fresh pointer entry. 1. Render `data-ctfl-entry-id` on each tracked element using the **resolved** entry id, not the baseline id. The [resolve-and-render helper](#resolving-entries-and-rendering-the-result) already writes it, alongside `data-ctfl-optimization-id`, `data-ctfl-optimization-context-id`, and `data-ctfl-variant-index` when the entry came from an optimization. 2. Leave the defaults on when your consent policy allows them. Use the constructor's `autoTrackEntryInteraction` only to opt out of an interaction type you must not observe. 3. For click tracking, use semantic clickable elements (`