Contentful Personalization & Analytics
    Preparing search index...

    Integrating the Optimization Web SDK in a web app

    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.

    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 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 instead. If your app renders on the server with Next.js, use the Next.js App Router guide or the Next.js Pages Router guide.

    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.

    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 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 client.

      Copy this:

      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).

      Before you resolve, call page() once: a page event asks the Experience API who this visitor is and returns their current variant selections, so the SDK has optimization state to use when you resolve the entry immediately after.

      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. The render step is a minimal placeholder — substitute your own field rendering, template, or DOM update.

      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: 'main',
      space: 'your-space-id',
      })

      const optimization = new ContentfulOptimization({
      clientId: 'your-optimization-client-id',
      environment: 'main',
      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 so the SDK has current selections before you resolve.
      await optimization.page()

      const baselineEntry = await contentfulClient.getEntry('hero-entry-id', {
      include: 10, // resolve linked experience and variant entries before rendering
      locale: APP_LOCALE, // one concrete locale — never withAllLocales / locale=*
      })

      // Pass the baseline entry; the SDK uses the selections from the page() call above.
      // On denied consent / no variant / unresolved links / all-locale payload this returns baselineEntry.
      const { entry } = optimization.resolveOptimizedEntry(baselineEntry)

      const hero = document.querySelector<HTMLElement>('#hero')
      if (hero) hero.textContent = 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.

    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.

    Table of Contents

    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 Optimization project values — client ID and environment, from your Optimization project settings. 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).

    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.

    Note

    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.

    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. clientId and environment identify your Optimization project. 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.

    Adapt this to your use case: the shared module a real app imports everywhere, with app metadata and API overrides.

    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: 'main',
    space: 'your-space-id',
    })

    // Reuse this singleton across route, render, and tracking handlers.
    export const optimization = new ContentfulOptimization({
    clientId: 'your-optimization-client-id',
    environment: 'main',
    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.

    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) 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). 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).
    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.

    // 1. The instance is usable immediately after construction.
    const optimization = new ContentfulOptimization({ clientId, 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()

    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 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, and it fetches by entry ID through that client whenever you call fetchContentfulEntry(id) or fetchOptimizedEntry(id). The client stays yours; the SDK uses its getEntry() and getEntries() methods.

    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.

    import * as contentful from 'contentful'

    const APP_LOCALE = 'en-US'
    const INCLUDE_DEPTH = 10

    const contentfulClient = contentful.createClient({
    accessToken: 'your-contentful-delivery-token',
    environment: 'main',
    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 then merges your contentful.defaultQuery, any per-call query, the SDK locale as a fallback, and include: 10 into each getEntry() call, 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; fetchContentfulEntries(entries) preserves descriptor order and uses getEntries() for multiple uncached entries with the same normalized query, split into 100-ID chunks for large fetches. prefetchManagedEntries(entries) returns server handoff objects for framework adapters. fetchOptimizedEntry(id) fetches and resolves in one call (see Resolving entries and rendering the result).

    Adapt this to your use case: the managed path — configure the client once, then fetch by ID.

    import * as contentful from 'contentful'
    import ContentfulOptimization from '@contentful/optimization-web'

    const contentfulClient = contentful.createClient({
    accessToken: 'your-contentful-delivery-token',
    environment: 'main',
    space: 'your-space-id',
    })

    const optimization = new ContentfulOptimization({
    clientId: 'your-optimization-client-id',
    locale: 'en-US',
    // Hand the SDK your client; it calls getEntry() through it. The client stays yours.
    contentful: { client: contentfulClient },
    })

    // getEntry() through your client, with include: 10 and the SDK locale merged in.
    const baselineEntry = await optimization.fetchContentfulEntry('hero-entry-id')

    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.

    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 and render whatever the SDK hands back.

    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).

    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).

    If you configured the managed path (contentful: { client }), fetchOptimizedEntry(id, options?) fetches and resolves in one call and returns the same fields plus the baselineEntry it fetched. Use it 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.

    // options?: { query?, selectedOptimizations? } — omit to use current SDK state.
    const { entry, baselineEntry, selectedOptimization } =
    await optimization.fetchOptimizedEntry('hero-entry-id')

    Two facts hold everywhere:

    • The resolved entry is a base contentful Entry. entry.fields is typed loosely, so if your render code expects a narrower type, cast it — entry as YourEntryType. This direct cast works for the common cases, including .withoutUnresolvableLinks-narrowed types. Only if TypeScript rejects a cast for a genuinely disjoint type do you need entry as unknown as YourEntryType.
    • Fallback contract. When consent is denied, no variant applies, links are unresolved, or the payload was all-locale, resolveOptimizedEntry() returns the baseline entry. Your UI never breaks; it falls back to default content — this is why the quick start renders correctly even before you author a variant.

    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.

    async function renderEntry(entryId: string, element: HTMLElement): Promise<void> {
    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, optimizationContextId, selectedOptimization } =
    optimization.resolveOptimizedEntry(baselineEntry)

    element.textContent = 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)
    }
    }

    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).

    Copy this:

    const result = await optimization.page()
    

    Adapt this to your use case: an SPA route tracker with stable route keys, wired to your router.

    function getRouteKey(): string {
    return `${window.location.pathname}${window.location.search}`
    }

    async function trackRoute(): Promise<void> {
    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

    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.

    const optimization = new ContentfulOptimization({
    clientId: 'your-optimization-client-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.

    const optimization = new ContentfulOptimization({
    clientId: 'your-optimization-client-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.

    // 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). For the cross-SDK policy model, see Consent management in the Optimization SDK Suite.

    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).
    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.

    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.

    1. Render data-ctfl-entry-id on each tracked element using the resolved entry id, not the baseline id. The resolve-and-render helper 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 (<button>, <a href>) or mark a non-semantic clickable path with data-ctfl-clickable="true".
    4. Use tracking.enableElement(...) for DOM the attribute path cannot express, and track() for custom business events (quote requests, form completions, checkout milestones).
    5. Page and identify events can be sent before full consent, but entry views, clicks, and hovers stay blocked until consent (or allowedEventTypes) permits them.

    Follow this pattern: opt one detector out globally.

    const optimization = new ContentfulOptimization({
    clientId: 'your-optimization-client-id',
    // Opt out of interactions your consent and analytics policy does not permit.
    autoTrackEntryInteraction: { hovers: false },
    })

    Adapt this to your use case: enable manual element tracking and emit a business event.

    // Manual element data takes precedence over data-ctfl-* attributes on the same element.
    optimization.tracking.enableElement('views', element, {
    data: {
    entryId: entry.sys.id,
    optimizationContextId,
    optimizationId: selectedOptimization?.experienceId,
    variantIndex: selectedOptimization?.variantIndex,
    },
    dwellTimeMs: 1000,
    })

    await optimization.track({
    event: 'quote_requested',
    properties: { plan: 'enterprise', source: 'pricing-page' },
    })

    Use tracking.disableElement(...) to force-disable one element, or tracking.clearElement(...) to remove a manual override so recycled DOM nodes do not keep stale entry data. For thresholds, attribute precedence, and delivery paths, see Interaction tracking in Web SDKs.

    Integration category: Common but policy-dependent

    Identify a visitor only when your app knows who they are or has policy-approved traits to send. Reset profile state when the active visitor changes or logs out.

    1. Call identify({ userId, traits }) after sign-in, account lookup, or persisted auth refresh when your consent and identity policy permits the association. userId is required.
    2. Call reset() on logout, account switch, or consent withdrawal that ends profile continuity. It clears SDK profile state, selected optimizations, route dedupe, and the ctfl-opt-aid cookie — but not your own sessions, cookies, or CMP records; clear those separately.
    3. Emit another page() after a reset when the app still needs browser-side optimization.

    Adapt this to your use case: login and logout handlers wired to the SDK actions.

    async function handleLogin(user: { id: string; plan: string }): Promise<void> {
    await optimization.identify({
    userId: user.id,
    traits: { authenticated: true, plan: user.plan },
    })
    }

    async function handleLogout(): Promise<void> {
    optimization.reset() // clears SDK profile + anonymous-id cookie, not app/CMP records
    await optimization.page() // re-establish anonymous optimization state
    }

    When persistence consent is true, the SDK can restore profile continuity from localStorage and the readable ctfl-opt-aid cookie; when it is false or unset, it does not load durable continuity. For cross-runtime identity behavior, see Profile synchronization between client and server.

    Web Components entry rendering

    Integration category: Optional

    The optional Web Components entrypoint provides vanilla custom elements so you can resolve and render entries declaratively in markup instead of driving resolveOptimizedEntry() by hand. It is side-effect-free until you register the elements.

    1. Import and call defineContentfulOptimizationElements() once from @contentful/optimization-web/web-components before using the elements. It registers <ctfl-optimization-root> and <ctfl-optimized-entry>.

    2. Use one <ctfl-optimization-root> for entries that share one SDK instance. The root creates the SDK from its attributes and assigned properties, reuses an existing window.contentfulOptimization instance automatically if one is already present, and lets you pass an explicit instance by assigning its sdk property.

    3. Pass simple config as attributes (client-id, environment, locale, live-updates), and structured config as DOM properties (defaults, api, trackEntryInteraction, sdk, onStatesReady) — attributes are strings, so objects must be assigned as properties.

    4. Give each <ctfl-optimized-entry> its entry one of two ways:

      • Manual: assign the fetched entry to the baselineEntry property (an object, so not an attribute). You fetch the entry yourself and the element resolves it.
      • Managed: set the SDK-owned entry-id attribute (or the entryId property, plus an optional entryQuery property) and the element fetches and resolves by ID for you — no manual fetch. This works only when the shared SDK instance carries a Contentful client (contentful: { client }), so use a reused window.contentfulOptimization or an assigned sdk that was configured that way; a root that builds its SDK from client-id/environment/locale alone has no client to fetch through.

      Per-entry tracking overrides use the track-views, track-clicks, track-hovers, and live-updates attributes either way.

    5. Listen for ctfl-entry-loading, ctfl-entry-resolved, and ctfl-entry-error on an entry element to render app-owned UI; the root emits ctfl-root-ready and ctfl-root-error.

    The data-entry-id below is an example name you invent — the SDK does not read it. The SDK-owned attribute is entry-id (no data- prefix), shown in the managed example below. Keep the two distinct.

    Adapt this to your use case: the manual path — you fetch and assign baselineEntry, then render on resolve. Here data-entry-id is your own lookup key, not the SDK's entry-id attribute.

    import {
    type ContentfulOptimizationRootElement,
    type ContentfulOptimizedEntryElement,
    type ContentfulOptimizedEntryEventDetail,
    defineContentfulOptimizationElements,
    } from '@contentful/optimization-web/web-components'

    defineContentfulOptimizationElements()

    const root = document.querySelector<ContentfulOptimizationRootElement>('ctfl-optimization-root')
    if (root) {
    // Structured SDK options are assigned as properties, not string attributes.
    root.defaults = { consent: true }
    root.trackEntryInteraction = { hovers: false }
    }

    const entryElement = document.querySelector<ContentfulOptimizedEntryElement>(
    'ctfl-optimized-entry[data-entry-id]',
    )
    if (entryElement?.dataset.entryId) {
    // data-entry-id here is YOUR app's lookup key — an attribute you named, not the SDK's entry-id.
    const baselineEntry = await contentfulClient.getEntry(entryElement.dataset.entryId, {
    include: 10,
    locale: 'en-US',
    })

    // Assigning the structured baseline entry object triggers resolution.
    entryElement.baselineEntry = baselineEntry
    entryElement.addEventListener('ctfl-entry-resolved', (event) => {
    const { detail } = event as CustomEvent<ContentfulOptimizedEntryEventDetail>
    renderHero(detail.entry) // detail also carries resolvedData, selectedOptimization, snapshot
    })
    }

    Follow this pattern: the markup the manual script above drives. data-entry-id is the app's own attribute; the script reads it to decide what to fetch.

    <ctfl-optimization-root client-id="your-optimization-client-id" environment="main" locale="en-US">
    <ctfl-optimized-entry data-entry-id="hero-entry-id"></ctfl-optimized-entry>
    </ctfl-optimization-root>

    For the managed path, the SDK instance must carry a Contentful client, and the element takes the SDK-owned entry-id attribute (no data- prefix). Setting entry-id makes the element fetch and resolve by ID on its own — you write no fetch and assign no baselineEntry.

    Follow this pattern: managed markup — the SDK's own entry-id attribute drives the fetch.

    <!-- The shared SDK was constructed with contentful: { client }, so the element can fetch by ID. -->
    <ctfl-optimization-root>
    <ctfl-optimized-entry entry-id="hero-entry-id"></ctfl-optimized-entry>
    </ctfl-optimization-root>

    The entry-id attribute is SDK-owned: match the exact name and the element fetches through the configured client. The data-entry-id in the manual example is a reader-invented lookup key — the app names it and reads it to drive its own fetch. Do not treat one as the other.

    When the root owns the SDK instance, trackEntryInteraction defaults view, click, and hover tracking to enabled — the same defaults as the ContentfulOptimization constructor. Set the live-updates attribute on the root or an entry only when a rendered entry must re-resolve on later selection changes instead of keeping its first resolved value.

    Integration category: Optional

    Use merge tags when rendered Rich Text contains Contentful MergeTag entries (a personalized greeting, a location). Use Custom Flags when app behavior branches on a named flag rather than an optimized entry.

    1. Install a Rich Text renderer if your app does not already have one.
    2. Resolve merge tags while rendering Rich Text: for each embedded entry node, guard with isMergeTagEntry (from @contentful/optimization-web/api-schemas) and pass the node's target to getMergeTagValue. Omit the profile argument to use the SDK's current profile state.
    3. Keep the SDK locale aligned with the rendered Contentful locale when merge tags reference localized profile fields such as location.city or location.country.
    4. Read Custom Flags with getFlag(name) for a one-off read, or subscribe to states.flag(name) when UI must re-render as the flag changes. Reading a flag emits a flag-view event when consent and profile state permit it, and repeated reads of the same value are deduplicated.

    Copy this: Install the Rich Text renderer.

    pnpm add @contentful/rich-text-html-renderer @contentful/rich-text-types
    

    Adapt this to your use case: resolve merge tags while rendering Rich Text.

    import { documentToHtmlString } from '@contentful/rich-text-html-renderer'
    import { INLINES } from '@contentful/rich-text-types'
    import { isMergeTagEntry } from '@contentful/optimization-web/api-schemas'

    const html = documentToHtmlString(article.fields.body, {
    renderNode: {
    [INLINES.EMBEDDED_ENTRY]: (node) => {
    // Only merge-tag nodes resolve to a profile value; render others as usual.
    if (!isMergeTagEntry(node.data.target)) return ''
    // Omit the profile argument to use the SDK's current profile state.
    return optimization.getMergeTagValue(node.data.target) ?? ''
    },
    },
    })

    Adapt this to your use case: read and subscribe to a Custom Flag.

    const NAVIGATION_FLAG = 'new-navigation'

    // Flag reads and subscriptions emit flag-view events when consent and profile state permit it.
    document.body.dataset.newNavigation = String(optimization.getFlag(NAVIGATION_FLAG) === true)

    optimization.states.flag(NAVIGATION_FLAG).subscribe((value) => {
    document.body.dataset.newNavigation = String(value === true)
    })

    Merge tags and entry replacement use different mechanics: entry replacement swaps the whole entry for its variant; merge tags read profile-backed values from current SDK state.

    Integration category: Optional

    Use analytics forwarding when your app already sends events to a tag manager, customer-data platform, or analytics destination. The SDK still sends its own events to Contentful; forwarding is application-owned, and your app decides which approved Contentful context, if any, is also forwarded.

    1. Register one app-level states.eventStream subscription after SDK initialization.
    2. Dedupe forwarded records by messageId. To receive only future events, read the current messageId before subscribing and skip it.
    3. Forward only events and fields your governance policy approves, gated by the same consent and destination policy that governs the rest of your analytics stack.
    4. Use states.blockedEventStream and destination debuggers to validate consent behavior.

    Follow this pattern:

    const forwardedMessageIds = new Set<string>()
    const initialMessageId = optimization.states.eventStream.current?.messageId

    const analyticsSubscription = optimization.states.eventStream.subscribe((event) => {
    if (!event) return
    if (forwardedMessageIds.has(event.messageId)) return
    if (event.messageId === initialMessageId) {
    forwardedMessageIds.add(event.messageId) // skip the snapshot emitted on subscribe
    return
    }
    if (!canForwardSdkEvent(event)) return // your governance + consent allow-list

    forwardedMessageIds.add(event.messageId)
    analytics.track(`Contentful ${event.type}`, pickContentfulEventProperties(event))
    })

    window.addEventListener('beforeunload', () => {
    analyticsSubscription.unsubscribe()
    })

    For destination mappings, consent alignment, dedupe, and governance, see Forwarding Optimization SDK context to analytics and tag-management tools.

    Integration category: Optional

    The preview panel is a separate browser package for development, preview, and staging workflows — including forcing a specific variant to verify a targeted experience. It attaches a Lit-based panel to document.body, reads preview content through a Contentful Delivery API client, and talks to your Web SDK instance through the browser preview bridge.

    1. Install @contentful/optimization-web-preview-panel only when your app needs browser authoring tooling.
    2. Gate the dynamic import behind an environment value so production bundles can drop preview code when the gate is replaced with false at build time.
    3. Attach the panel after the SDK singleton and either a Contentful client or pre-fetched preview entries exist. attachOptimizationPreviewPanel(...) uses window.contentfulOptimization by default; pass optimization when your instance is not the global one.
    4. Pass a CSP nonce when strict Content Security Policy rules require one for Lit styles.

    Copy this: Install the preview package.

    pnpm add @contentful/optimization-web-preview-panel
    

    Adapt this to your use case: an environment-gated dynamic import and attach.

    function attachPreviewPanel(): void {
    // Keep preview code behind an environment gate so production bundles can remove it.
    if (import.meta.env.PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL !== 'true') return

    void import('@contentful/optimization-web-preview-panel')
    .then(async ({ default: attachOptimizationPreviewPanel }) => {
    await attachOptimizationPreviewPanel({
    contentful: contentfulClient,
    optimization, // omit when the panel can use window.contentfulOptimization
    })
    })
    .catch((error: unknown) => {
    console.warn('Failed to attach the Contentful Optimization preview panel.', error)
    })
    }

    attachPreviewPanel()

    If your app already loads preview content through GraphQL, SSR, a loader, or a proxy, pass entries: { audiences, experiences } instead of contentful; when entries is provided the panel does not fetch through contentful. While the panel drawer is open, Web Components entries live- update so preview overrides render; manual renderers still need a states.selectedOptimizations subscription to react to preview overrides.

    Integration category: Advanced or production-only

    Use this integration when the same app uses @contentful/optimization-node on the server and @contentful/optimization-web in the browser, and you want the same visitor's profile to carry across the boundary.

    1. Decide whether the server or browser owns the first personalization decision for each route.
    2. Share the anonymous profile identifier through the SDK's ANONYMOUS_ID_COOKIE value (ctfl-opt-aid) when consent permits durable profile continuity. This cookie is SDK-owned — match the exact name; do not invent your own.
    3. Write the cookie from the server with Path=/ and a same-site policy that matches your app, and do not mark it HttpOnly — the browser SDK must read it to keep the same profile after takeover.
    4. Use trackCurrentPage({ initialPageEvent: 'skip', ... }) for the first browser route when the server already emitted the same initial page event, so the browser does not duplicate it.
    5. On consent denial or revocation, clear the shared cookie and avoid persisting a returned profile id. Treat server-rendered personalized HTML as personalized output for cache policy.

    Follow this pattern: build the shared anonymous-id Set-Cookie on the server.

    import { ANONYMOUS_ID_COOKIE } from '@contentful/optimization-web/constants'

    function buildAnonymousIdSetCookie(id: string | undefined): string {
    if (!id) return `${ANONYMOUS_ID_COOKIE}=; Max-Age=0; Path=/`
    // Browser code must be able to read this cookie for Web SDK continuity — no HttpOnly.
    return `${ANONYMOUS_ID_COOKIE}=${id}; Path=/; SameSite=Lax`
    }

    ANONYMOUS_ID_COOKIE re-exports the core constant and equals 'ctfl-opt-aid'. For the lower-level mechanics, see Profile synchronization between client and server.

    Integration category: Advanced or production-only

    Configure these only after your privacy, analytics, and platform owners agree on the event posture.

    1. Set allowedEventTypes: [] when no Optimization event may emit before explicit consent. (The default allows identify and page.)
    2. Use cookie (domain, expires in days — default 365) when the profile-id cookie needs a specific domain or lifetime.
    3. Use queuePolicy when the default retry and offline-queue behavior does not match your limits.
    4. Use onEventBlocked (and states.blockedEventStream) for diagnostics when consent or allowedEventTypes block events.

    Adapt this to your use case:

    const optimization = new ContentfulOptimization({
    clientId: 'your-optimization-client-id',
    allowedEventTypes: [], // block all Optimization events until consent is accepted
    cookie: { domain: '.example.com', expires: 180 },
    queuePolicy: { offlineMaxEvents: 100 },
    onEventBlocked: (event) => diagnostics.logBlockedOptimizationEvent(event),
    })

    Blocked events are not replayed when consent later changes. If the current route, flag, or entry state still qualifies after consent, the SDK can emit a fresh current-state event.

    Before release, verify these behaviors in the target deployment:

    • Credentials and runtime configuration — the browser receives the intended Optimization client id, environment, Contentful space/environment/host, API base URLs, app metadata, and locale; no Management API token is exposed to the browser.
    • Consent behavior — default-on integrations set defaults: { consent: true } only when policy permits; CMP-driven integrations keep consent unset until a choice exists, use allowedEventTypes: [] for strict opt-in, block non-allowed events before consent, and clear profile continuity on withdrawal.
    • Event deliverypage(), identify(), track(), entry views/clicks/hovers, and flag views are accepted or blocked exactly as policy expects, and states.blockedEventStream stays empty for events that should be allowed.
    • Content fallback — missing selections, unresolved links, all-locale CDA responses, or a failed Experience API call render baseline content instead of breaking the page.
    • Duplicate-tracking prevention — SPA routes use stable route keys via trackCurrentPage, subscriptions register once per app root, messageId dedupe is applied before forwarding, the resolved (not baseline) entry id is used for tracking, and element tracking is not enabled twice for the same node.
    • Privacy and governance — profile identifiers, traits, forwarded fields, localStorage usage, the ctfl-opt-aid cookie, and retention match the app's approved policy.
    • Local validation path — compare the app against the Web SDK reference implementation and run its checks locally.

    Copy this:

    pnpm implementation:run -- web-sdk typecheck
    pnpm test:e2e:web-sdk
    Symptom Likely cause Check
    Entry stays on baseline No variant applies, denied consent, unresolved Contentful links, or an all-locale payload Author a variant that targets you, check consent, fetch one locale with enough include
    The variant never appears even though it is authored Your test visitor does not match the experience's audience, or no accepted page() ran first Target all visitors for a first test or force the variant with the preview panel; confirm page() was accepted
    resolveOptimizedEntry() always returns the baseline No selected optimizations yet, the entry is not optimized, links are unresolved, or all-locale Verify the preceding page()/identify() result, CDA include, locale, and fields.nt_experiences/variants
    entry.fields.x shows a type error The resolved entry is a base Entry, wider than your component's type Cast it: entry as YourEntryType (add as unknown only if TS rejects a genuinely disjoint type)
    ContentfulOptimization is already initialized More than one instance in the same browser runtime Reuse the module singleton, or call destroy() only in teardown paths
    SPA page events duplicate Route changes call page() directly without route-key dedupe Use trackCurrentPage() with a stable routeKey
    track() or interaction events behave as blocked Consent is unset or false, or the event type is not allow-listed Inspect states.consent.current, allowedEventTypes, onEventBlocked, and states.blockedEventStream
    Automatic click tracking does not emit The event target is not on a clickable path Use native clickable elements or add data-ctfl-clickable="true" to the clickable path
    Custom Flag reads do not emit flag-view events Consent or profile state is missing, or the same value was already tracked Verify event consent, profile state, and that the flag value actually changed
    Hybrid browser sessions start with a different anonymous profile Server and browser do not share the same readable anonymous-id cookie Verify ctfl-opt-aid path, same-site settings, consent state, and that the cookie is readable by browser code

    Use the Web SDK package README for package orientation, and the generated Web SDK reference for exhaustive API signatures.