Contentful Personalization & Analytics
    Preparing search index...

    Integrating the Optimization Web SDK in a web app

    Use this guide when you want to implement client-side personalization and analytics in a browser application such as a static site, multi-page app, SPA, or custom frontend runtime using @contentful/optimization-web.

    The examples below use vanilla browser APIs, but the same flow applies in any frontend stack where you manage the Web SDK instance yourself. If you are building a React application and want providers, hooks, and router adapters, use the React Web guide instead.

    Table of Contents

    The Web SDK is the browser-side package in the Optimization SDK Suite. It lets consumers:

    • evaluate browser events such as page(), identify(), and track() and receive profile data, selected optimizations, and Custom Flag changes
    • persist consent and, when persistence consent permits it, profile state, selected optimizations, changes, and the anonymous profile identifier in browser-managed storage
    • resolve optimized Contentful entries in the browser after baseline content has been fetched
    • resolve merge tags against the current profile
    • emit page, component view, click, hover, and custom business events from the browser
    • automatically or manually track entry interactions in the DOM
    • continue the same anonymous journey as the Node SDK in hybrid SSR + browser applications

    The Web SDK is stateful. After page() or identify() runs, the returned profile, changes, and selectedOptimizations are stored in SDK state, so later calls such as resolveOptimizedEntry() and getFlag() can use current state without you threading response objects through the entire UI.

    The Web SDK also does not replace your Contentful delivery client. Your application still fetches entries from Contentful, renders the DOM, decides how consent works, and decides when user identity becomes known.

    In practice, most Web SDK integrations follow this high-level sequence:

    1. Create one SDK instance for the current page or SPA runtime.
    2. Apply the application's consent policy: seed defaults: { consent: true } for default-on integrations, or call consent(true | false) from a consent UI or CMP callback.
    3. Emit page() on the first load and again whenever the active route changes.
    4. Fetch baseline Contentful entries and resolve variants with resolveOptimizedEntry().
    5. Render flags and merge tags from current SDK state.
    6. Call identify() when the user becomes known, and reset() when identity must be discarded.
    7. Enable automatic or manual entry tracking and send follow-up business events with track(), trackView(), trackClick(), or trackHover().
    8. Subscribe to states so the UI rerenders when profile or optimization state changes.

    The Web-focused reference implementations in this repository show that pattern in working applications:

    Install the package in your web application:

    pnpm add @contentful/optimization-web
    

    Create the SDK once and reuse it for the lifetime of the page or SPA runtime:

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

    const APP_CONFIG = {
    contentfulAccessToken: 'your-contentful-token',
    contentfulEnvironment: 'main',
    contentfulSpaceId: 'your-space-id',
    optimizationClientId: 'your-optimization-client-id',
    optimizationEnvironment: 'main',
    experienceBaseUrl: 'https://experience.ninetailed.co/',
    insightsBaseUrl: 'https://ingest.insights.ninetailed.co/',
    } as const

    const rawContentfulClient = contentful.createClient({
    accessToken: APP_CONFIG.contentfulAccessToken,
    environment: APP_CONFIG.contentfulEnvironment,
    space: APP_CONFIG.contentfulSpaceId,
    })

    export const optimization = new ContentfulOptimization({
    clientId: APP_CONFIG.optimizationClientId,
    environment: APP_CONFIG.optimizationEnvironment,
    locale: 'en-US',
    app: {
    name: 'my-web-app',
    version: '1.0.0',
    },
    api: {
    experienceBaseUrl: APP_CONFIG.experienceBaseUrl,
    insightsBaseUrl: APP_CONFIG.insightsBaseUrl,
    },
    contentfulLocales: {
    default: 'en-US',
    supported: ['en-US', 'de-DE', 'fr-FR'],
    },
    autoTrackEntryInteraction: { views: true, clicks: true, hovers: true },
    logLevel: 'warn',
    })

    export const contentfulClient = optimization.withOptimizationLocale(rawContentfulClient)

    Use contentfulLocales.default for single-locale apps, and add contentfulLocales.supported when the app needs browser locale matching across multiple Contentful locales. Copy those codes from Contentful locale settings or the CMA locale list. The resolved optimization.locale, when present, is the Contentful locale code used by withOptimizationLocale() and by default Experience API localization unless you provide an explicit api.locale override.

    For the full matching rules, configuration cases, and Experience API locale behavior, see Locale handling in the Optimization SDK Suite.

    Treat that SDK as a singleton. Do not create a new ContentfulOptimization instance per component, per route render, or per click handler. In browser runtimes, the constructor also attaches the instance to window.contentfulOptimization and throws if another instance is already active.

    Notes:

    • The reference implementations use PUBLIC_... environment variable names. A consumer app can use any runtime-config mechanism that fits its bundler or deployment setup.
    • If your app is an SPA, keep the singleton alive across navigations. The web-sdk_react implementation demonstrates that pattern even though its rendering layer is React.
    • If you are not bundling JavaScript at all, the package README also shows direct UMD usage in a plain HTML page.

    The Web SDK exposes a browser-side consent() method, but your application still owns the consent policy and user experience.

    If your application policy permits Optimization by default and you do not render an end-user consent UI, seed accepted consent during initialization:

    export const optimization = new ContentfulOptimization({
    clientId: APP_CONFIG.optimizationClientId,
    defaults: { consent: true },
    })

    That starts all gated SDK events immediately and permits durable profile-continuity storage for profile, selected optimizations, changes, and the anonymous ID. Consent defaults do not change feature defaults such as autoTrackEntryInteraction; configure those separately when you want automatic tracking enabled.

    If your application policy depends on user choice, leave SDK consent unset at startup and call consent(true | false) from an application-owned banner or CMP callback:

    const acceptButton = document.querySelector<HTMLButtonElement>('#consent-accept')
    const rejectButton = document.querySelector<HTMLButtonElement>('#consent-reject')

    acceptButton?.addEventListener('click', () => {
    optimization.consent(true)
    })

    rejectButton?.addEventListener('click', () => {
    optimization.consent(false)
    })

    optimization.states.consent.subscribe((consent) => {
    document.documentElement.dataset.optimizationConsent = String(consent)
    })

    Important behavior:

    • consent(true) enables the full event surface and starts any auto-enabled entry interaction trackers
    • consent(false) keeps the browser in a denied state and blocks non-allowed event types
    • consent is persisted by the Web SDK, so the next page load starts from the remembered value
    • reset() is not a consent API; it clears profile-related state but intentionally preserves the consent choice

    By default, only identify and page are allowed before consent is explicitly set. Other event types are blocked until consent is granted or the event type is allow-listed. For cross-SDK consent policy guidance, see Consent management in the Optimization SDK Suite.

    If your policy requires a stricter pre-consent posture, configure allowedEventTypes: [] during initialization instead of relying on the default ['identify', 'page'].

    If events are allowed but durable profile continuity must stay session-only, call optimization.consent({ events: true, persistence: false }).

    In a traditional multi-page site, calling page() after initialization is usually enough because the Web SDK can derive browser page properties such as URL, referrer, title, query parameters, and viewport size automatically.

    That is exactly what the vanilla and hybrid reference implementations do:

    await optimization.page()
    

    For SPAs or other client-side routing solutions, emit another page event whenever the active route changes:

    function getCurrentPageProperties() {
    const url = new URL(window.location.href)

    return {
    path: url.pathname,
    query: Object.fromEntries(url.searchParams.entries()),
    referrer: document.referrer,
    search: url.search,
    title: document.title,
    url: url.toString(),
    }
    }

    async function emitPage(): Promise<void> {
    const page = getCurrentPageProperties()

    await optimization.page({
    name: page.title,
    properties: page,
    })
    }

    void emitPage()

    router.onRouteChange(() => {
    void emitPage()
    })

    Replace router.onRouteChange(...) with whatever hook your framework exposes. The important rule is that the browser emits a new page() event whenever the user lands on a different route-like experience.

    Once the page has been evaluated, fetch baseline Contentful entries the same way your application normally does, then resolve each entry with resolveOptimizedEntry().

    async function renderEntry(entryId: string, element: HTMLElement): Promise<void> {
    const baseline = await contentfulClient.getEntry<MarketingHeroSkeleton>(entryId, {
    include: 10,
    })

    const { entry, selectedOptimization } = optimization.resolveOptimizedEntry(baseline)

    element.textContent = String(entry.fields.headline ?? '')

    // Application-owned rendering metadata for later rerenders.
    element.dataset.ctflBaselineId = baseline.sys.id

    // SDK-owned auto-tracking metadata for the resolved entry.
    element.dataset.ctflEntryId = entry.sys.id

    if (selectedOptimization) {
    if (selectedOptimization.experienceId) {
    element.dataset.ctflOptimizationId = selectedOptimization.experienceId
    } else {
    delete element.dataset.ctflOptimizationId
    }

    if (selectedOptimization.sticky !== undefined) {
    element.dataset.ctflSticky = String(selectedOptimization.sticky)
    } else {
    delete element.dataset.ctflSticky
    }

    if (selectedOptimization.variantIndex !== undefined) {
    element.dataset.ctflVariantIndex = String(selectedOptimization.variantIndex)
    } else {
    delete element.dataset.ctflVariantIndex
    }
    } else {
    delete element.dataset.ctflOptimizationId
    delete element.dataset.ctflSticky
    delete element.dataset.ctflVariantIndex
    }
    }

    Replace MarketingHeroSkeleton and headline with the generated Contentful skeleton type and field names your application already uses.

    This is the main browser-side personalization loop:

    1. Ask Optimization for the current profile's selected variants by calling page() or identify().
    2. Fetch the baseline Contentful entry with one CDA locale.
    3. Resolve the optimized entry variant before rendering it into the DOM.

    Configure contentfulLocales.default once for single-locale apps, and add contentfulLocales.supported for localized apps that need browser locale matching. If the app locale changes after initialization, call optimization.setLocale(nextLocale) and then run the app's normal profile and content refresh flow. The recommended withOptimizationLocale() helper lets Contentful entry fetches use that same resolved locale by default; data layers that need direct control can pass optimization.locale explicitly. Fetching entries with contentful.js withAllLocales or raw CDA locale=* returns locale-keyed fields, but the SDK resolver expects a standard single-locale CDA entry where fields.nt_experiences and fields.nt_variants are direct field values. See Entry personalization and variant resolution for the entry contract and Locale handling in the Optimization SDK Suite for the broader locale model.

    In a stateful browser integration, the usual rerender trigger is states.selectedOptimizations:

    async function renderAllEntries(): Promise<void> {
    const entryElements = Array.from(document.querySelectorAll<HTMLElement>('[data-entry-id]'))

    await Promise.all(
    entryElements.map(async (element) => {
    const baselineId = element.dataset.ctflBaselineId ?? element.dataset.entryId

    if (!baselineId) return

    await renderEntry(baselineId, element)
    }),
    )
    }

    void renderAllEntries()

    optimization.states.selectedOptimizations.subscribe((selectedOptimizations) => {
    if (selectedOptimizations === undefined) return

    void renderAllEntries()
    })
    Important

    Keep the original baseline entry ID somewhere stable, such as data-ctfl-baseline-id or your own view-model state. Otherwise, a rerender can accidentally try to resolve a previously selected variant as though it were the baseline entry.

    The Web SDK also exposes helpers for profile-aware merge tags and Custom Flags.

    If a Rich Text field contains merge-tag entries, resolve them against current SDK state while rendering the field:

    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) => {
    if (!isMergeTagEntry(node.data.target)) return ''

    return optimization.getMergeTagValue(node.data.target) ?? ''
    },
    },
    })

    That is the same basic pattern used in the reference implementations, even when the final Rich Text renderer differs.

    If a merge tag references localized profile fields such as location.city or location.country, its resolved value follows the localized profile values returned by the Experience API. In this guide, contentfulLocales and the current SDK locale let the SDK keep the default Experience API locale aligned with the CDA entry fetch locale.

    Use getFlag() when the current optimization response contains Custom Flag changes:

    const showNewNavigation = optimization.getFlag('new-navigation') === true
    

    If you want the UI to react to later updates, subscribe to the flag state:

    optimization.states.flag('new-navigation').subscribe((value) => {
    document.body.dataset.newNavigation = String(value === true)
    })

    Unlike the stateless Node SDK, the stateful Web SDK automatically emits flag-view tracking when you read a flag via getFlag() or states.flag(name). Both paths deduplicate tracking events using deep equality, so repeated reads of the same resolved value emit only one flag view event.

    Call identify() when the browser session becomes associated with a known user, such as after a sign-in, account lookup, or persisted auth refresh:

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

    That lets the browser stitch the current anonymous profile to a known identity and update profile state for later entry resolution, flags, and event attribution.

    To discard the current browser identity, call reset():

    async function handleLogout(): Promise<void> {
    optimization.reset()

    // Create a fresh anonymous profile immediately if the app still needs browser-side optimization.
    await optimization.page()
    }

    That is the same shape used in the vanilla reference implementation. reset() clears the anonymous ID cookie, cached profile data, cached flag changes, selected optimizations, and entry-tracking runtime state. It does not clear consent.

    7. Track entry interactions and follow-up events

    The Web SDK can emit more than page and identify events. Common browser-side cases are:

    • automatic entry view, click, and hover tracking from the DOM
    • manual trackView() calls for UI regions that are not directly tied to a Contentful entry
    • track() calls for business events such as quote requests or sign-up milestones
    • trackClick() and trackHover() calls when the app has custom interaction logic that must not rely on DOM auto-detection

    Automatic entry tracking

    If you enable autoTrackEntryInteraction, add the standard data-ctfl-* attributes to the rendered element that contains the resolved entry content:

    <article
    data-ctfl-entry-id="resolved-entry-id"
    data-ctfl-optimization-id="experience-id"
    data-ctfl-sticky="true"
    data-ctfl-variant-index="1"
    ></article>

    data-ctfl-entry-id is required. The other attributes are needed only when the current entry is an optimized variant.

    For click tracking, prefer semantic clickable elements such as <button> and <a>, or explicitly mark clickability with data-ctfl-clickable="true". The Web SDK can detect clicks on the tracked element itself, on a clickable ancestor, or on a clickable descendant inside the tracked entry.

    If your element structure does not fit the standard data-attribute pattern, force-enable tracking for a specific element:

    optimization.tracking.enableElement('views', element, {
    data: {
    entryId: resolvedEntry.sys.id,
    optimizationId: selectedOptimization?.experienceId,
    sticky: selectedOptimization?.sticky,
    variantIndex: selectedOptimization?.variantIndex,
    },
    })

    Use tracking.disableElement(...) to force-disable a specific element or tracking.clearElement(...) to remove a manual override and return it to automatic behavior. Manual API overrides take precedence over data-attribute overrides. After clearElement(...), the element falls back to attribute overrides first, then normal automatic behavior.

    For a deeper explanation of the runtime model, see Interaction tracking in Web SDKs.

    Interaction observers are passive with respect to host event flow. They do not call event.preventDefault() or event.stopPropagation().

    View tracking uses IntersectionObserver plus dwell-time timers. Track only relevant elements, disable tracking for elements that are no longer needed, and choose stable minVisibleRatio and dwellTimeMs values that match your UI so visibility cycles do not reset constantly.

    Hover tracking uses pointer and mouse enter/leave events with dwell-time timers. Tune dwellTimeMs and hoverDurationUpdateIntervalMs for pointer-heavy UIs so short pointer movement does not create unwanted event volume.

    Click tracking uses semantic clickability checks plus tracked-entry resolution. Prefer native clickable elements such as <button> and <a href>, role-based click targets, or data-ctfl-clickable="true" over relying only on JavaScript-assigned onclick handlers.

    Automatic elements can also use per-element data-ctfl-* overrides:

    Attribute Purpose
    data-ctfl-track-views Force-enable or force-disable view tracking for the element
    data-ctfl-view-duration-update-interval-ms Override periodic view-duration update interval
    data-ctfl-track-clicks Force-enable or force-disable click tracking
    data-ctfl-track-hovers Force-enable or force-disable hover tracking
    data-ctfl-hover-duration-update-interval-ms Override periodic hover-duration update interval

    Use the generated Web SDK reference for the complete option types behind these behaviors.

    Use track() for business events:

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

    The Web SDK is stateful, so most browser integrations can react to SDK state changes instead of passing OptimizationData objects through every UI layer.

    Useful streams include:

    • states.consent for consent UI
    • states.profile for identity-aware UI
    • states.selectedOptimizations for rerendering optimized entries
    • states.flag(name) for feature flag gates
    • states.eventStream for analytics debugging or local dev tooling
    • states.blockedEventStream for consent-gating diagnostics

    Example:

    const subscriptions = [
    optimization.states.profile.subscribe((profile) => {
    const badge = document.querySelector('#profile-id')
    if (badge) badge.textContent = profile?.id ?? 'anonymous'
    }),
    optimization.states.selectedOptimizations.subscribe((selectedOptimizations) => {
    if (selectedOptimizations === undefined) return

    void renderAllEntries()
    }),
    optimization.states.blockedEventStream.subscribe((blockedEvent) => {
    if (!blockedEvent) return

    console.info(`Blocked Optimization event: ${blockedEvent.type}`)
    }),
    ]

    window.addEventListener('beforeunload', () => {
    subscriptions.forEach((subscription) => subscription.unsubscribe())
    })

    Each observable immediately emits its current snapshot and then emits subsequent updates. If you need a synchronous read instead of a subscription, use .current, for example optimization.states.profile.current.

    Use this optional step when your browser app already sends events to a tag manager, customer-data platform, or analytics destination. The Optimization SDK still sends events to Contentful. Your application decides which approved Contentful context, if any, should also be forwarded.

    Reporting need Web SDK handoff
    SDK page, custom, view, click, or hover Subscribe once to optimization.states.eventStream.
    Business event attribution Add Contentful fields beside the existing track() or destination event call.
    Entry or variant attribution Use the resolved entry and selectedOptimization from the render/action path.
    Custom Flag attribution Forward from the same code path that reads or renders the flag.
    Consent or duplicate-delivery verification Use states.blockedEventStream, messageId dedupe, and destination debuggers.

    eventStream is a live handoff, not a replay queue. Register the subscription at SDK initialization; if the analytics destination is not ready yet, buffer forwarded payloads in application code with an explicit size, TTL, and drop policy.

    For a browser-owned analytics handoff, register one app-level subscription after constructing the SDK instance:

    const forwardedMessageIds = new Set<string>()

    const analyticsSubscription = optimization.states.eventStream.subscribe((event) => {
    if (!event) return
    if (forwardedMessageIds.has(event.messageId)) return
    if (!canForwardSdkEvent(event)) return

    forwardedMessageIds.add(event.messageId)

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

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

    Use Forwarding Optimization SDK context to analytics and tag-management tools for the canForwardSdkEvent and pickContentfulEventProperties helpers, vendor mappings, consent, identity, dedupe, and governance guidance.

    If your architecture uses both @contentful/optimization-node on the server and @contentful/optimization-web in the browser, let both runtimes continue the same anonymous journey by sharing the anonymous ID cookie.

    For the lower-level mechanics behind that handoff, see Profile synchronization between client and server.

    That is the pattern shown in the node-sdk+web-sdk reference implementation:

    • the server persists ANONYMOUS_ID_COOKIE with path: '/' and sameSite: 'lax' when consent permits profile continuity
    • the browser Web SDK reads the same cookie during initialization
    • after hydration, browser events continue from the same anonymous profile instead of starting over

    If browser code must read the cookie, do not mark it HttpOnly.

    This hybrid architecture can preserve more cache flexibility when the browser resolves personalized entries after hydration. If the server already embeds personalized HTML or profile-derived values, treat that response as personalized and avoid shared caching unless you vary on all relevant personalization inputs.

    Use these reference implementations when you want working repository examples instead of guide snippets:

    • Web Vanilla: vanilla browser initialization, consent handling, page(), entry resolution, merge tags, and automatic or manual interaction tracking.
    • Node SSR + Web SDK Vanilla: browser-side continuation of an SSR flow with the Web SDK and shared anonymous cookie persistence for Node and Web SDK continuity.
    • Web SDK React: SPA-style page() emission, consent updates, identify(), reset() patterns, resolved-entry rendering, and automatic and manual tracking metadata.