Use this guide when you want to implement browser-side personalization and analytics in a static
site, multi-page app, SPA, or custom frontend runtime using @contentful/optimization-web.
The examples use vanilla browser APIs. If you are building a React application and want official providers, hooks, components, and router adapters, use Integrating the Optimization React Web SDK in a React app instead.
Use this path when your application policy permits Optimization to start with accepted consent. If your policy requires an end-user choice first, complete the consent handoff section before sending events.
Install the browser SDK and a Contentful delivery client in your web application.
Copy this:
pnpm add @contentful/optimization-web contentful
Create one Web SDK instance for the page or SPA runtime, then emit one page() event, fetch one
single-locale Contentful entry, resolve the selected variant, and render it.
Adapt this to your use case:
import * as contentful from 'contentful'
import ContentfulOptimization from '@contentful/optimization-web'
const APP_LOCALE = 'en-US'
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,
// Only use default-on consent when application policy permits it.
defaults: { consent: true },
app: {
name: 'my-web-app',
version: '1.0.0',
},
})
// Emit the page event before resolving entries so selections are current.
const pageResult = await optimization.page()
const baselineEntry = await contentfulClient.getEntry('hero-entry-id', {
include: 10,
locale: APP_LOCALE,
})
// Passing [] falls back to the baseline when the page event is blocked or has no data.
const selectedOptimizations = pageResult.accepted ? pageResult.data?.selectedOptimizations : []
const { entry } = optimization.resolveOptimizedEntry(baselineEntry, selectedOptimizations ?? [])
const hero = document.querySelector<HTMLElement>('#hero')
if (hero) {
hero.textContent = String(entry.fields.headline ?? '')
}
Verify the hero renders from the selected variant when the visitor matches an optimization, or from the baseline entry when no optimization is selected or no Optimization data is available.
The full guide uses these setup items:
| Setup item | Category | Required for quick start | Where to configure |
|---|---|---|---|
@contentful/optimization-web package |
Required for first integration | Yes | Application package manager |
| Contentful delivery client package | Required for first integration | Yes | Application package manager and Contentful client factory |
Optimization client ID and optional non-main environment |
Required for first integration | Yes | Runtime configuration passed to new ContentfulOptimization(...) |
| Contentful space, environment, and access token | Required for first integration | Yes | Application-owned Contentful client configuration |
| Non-default Contentful CDA host | Common but policy-dependent | No | Application-owned Contentful client host or endpoint configuration |
| Application Contentful locale and SDK Experience/event locale | Required for first integration | Yes | Router, i18n layer, and SDK locale |
Single-locale Contentful entry and CDA include depth for variants |
Required for first integration | Yes | Contentful content model and CDA include depth |
| Initial page event | Required for first integration | Yes | Browser entrypoint or first SPA route |
| SPA route-change hook | Common but policy-dependent | No | Router or navigation layer |
| Consent and persistence policy | Common but policy-dependent | Conditional | SDK defaults, allowedEventTypes, consent(...), and application CMP or banner |
| Identity policy for known users | Common but policy-dependent | No | Authentication, account, or profile layer that calls identify(...) and reset() |
| State subscriptions for rerenders, diagnostics, or forwarding | Common but policy-dependent | No | SDK states.* subscribers and application teardown |
| Entry interaction tracking | Common but policy-dependent | No | SDK autoTrackEntryInteraction, tracking.*, and rendered data-ctfl-* attributes |
| Rich Text renderer packages for merge tags | Optional | No | @contentful/rich-text-html-renderer and @contentful/rich-text-types |
| Web Components entrypoint | Optional | No | @contentful/optimization-web/web-components |
@contentful/optimization-web-preview-panel package |
Optional | No | Environment-gated dynamic import and attachOptimizationPreviewPanel(...) |
| Analytics or tag-manager forwarding | Optional | No | Application-level states.eventStream subscriber |
| Preview panel CSP nonce | Advanced or production-only | No | Preview panel nonce option or window.litNonce |
| Shared Node/Web anonymous ID continuity | Advanced or production-only | No | Server cookies, ANONYMOUS_ID_COOKIE, and browser Web SDK initialization |
| Production event, privacy, and cache validation | Advanced or production-only | No | Release checklist, observability, and deployment configuration |
Keep the default path single-locale. Fetch entries for SDK resolution with one concrete Contentful
locale and enough include depth for linked optimization entries and variants. Do not pass
contentful.js withAllLocales results or raw CDA locale=* responses to
resolveOptimizedEntry().
Integration category: Required for first integration
The Web SDK is stateful. Create one SDK instance for the active page or SPA runtime and reuse it across components, route handlers, and interaction handlers.
@contentful/optimization-web and the Contentful delivery client your app uses.clientId, optional non-main environment, optional API base URLs,
locale, and app metadata to the constructor.window.contentfulOptimization and throws if another instance is already active.destroy() only for explicit teardown paths such as tests, hot reload, or a framework root
unmount that owns the instance.Adapt this to your use case:
import * as contentful from 'contentful'
import ContentfulOptimization from '@contentful/optimization-web'
const APP_CONFIG = {
contentfulAccessToken: 'your-contentful-delivery-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 APP_LOCALE = 'en-US'
export const contentfulClient = contentful.createClient({
accessToken: APP_CONFIG.contentfulAccessToken,
environment: APP_CONFIG.contentfulEnvironment,
space: APP_CONFIG.contentfulSpaceId,
})
// Reuse this singleton across route, render, and tracking handlers.
export const optimization = new ContentfulOptimization({
clientId: APP_CONFIG.optimizationClientId,
environment: APP_CONFIG.optimizationEnvironment,
locale: APP_LOCALE,
app: {
name: 'my-web-app',
version: '1.0.0',
},
api: {
experienceBaseUrl: APP_CONFIG.experienceBaseUrl,
insightsBaseUrl: APP_CONFIG.insightsBaseUrl,
},
logLevel: 'warn',
})
The Web SDK does not replace the Contentful delivery client. Your application still owns Contentful credentials, entry fetching, routing, rendering, consent policy, identity policy, and cache policy.
For locale mechanics, see
Locale handling in the Optimization SDK Suite.
Changing the SDK locale with optimization.setLocale(nextLocale) updates subsequent Experience API
requests and event context. It does not refetch Contentful entries or clear application caches.
Integration category: Common but policy-dependent
The Web SDK exposes consent state and event gates. Your application owns the consent policy, user experience, legal basis, CMP records, and downstream destination policy.
identify and page while event consent is
undefined or false.allowedEventTypes: [], leave defaults.consent unset, and
call consent(...) from the application-owned banner or CMP callback.allowedEventTypes only after privacy review approves which event types can emit while
event consent is undefined or false.states.blockedEventStream during development to verify which calls are blocked by
consent.Copy this:
const optimization = new ContentfulOptimization({
clientId: APP_CONFIG.optimizationClientId,
// Starts event emission and durable profile continuity immediately.
defaults: { consent: true },
})
Use strict opt-in when no Optimization event can emit until the user accepts consent:
Copy this:
const optimization = new ContentfulOptimization({
clientId: APP_CONFIG.optimizationClientId,
// Replaces the Web SDK default pre-consent allow-list of identify and page.
allowedEventTypes: [],
})
Adapt this to your use case:
const acceptButton = document.querySelector<HTMLButtonElement>('#consent-accept')
const rejectButton = document.querySelector<HTMLButtonElement>('#consent-reject')
acceptButton?.addEventListener('click', () => {
// Boolean consent updates both event and persistence consent.
optimization.consent(true)
})
rejectButton?.addEventListener('click', () => {
optimization.consent(false)
})
optimization.states.consent.subscribe((consent) => {
document.documentElement.dataset.optimizationConsent = String(consent)
})
Boolean consent calls update both event consent and durable profile-continuity persistence consent. Use this form when events can emit but profile, selected optimizations, changes, and the anonymous ID must not persist beyond the session:
Copy this:
optimization.consent({ events: true, persistence: false })
Calling consent(false) blocks subsequent non-allowed events and clears SDK-managed durable
profile-continuity storage. It does not clear the active in-memory profile or erase application,
server, CMP, or third-party records. Call reset() when the active browser profile must be removed
from the current session.
For cross-SDK policy guidance, see Consent management in the Optimization SDK Suite.
Integration category: Required for first integration
page() evaluates the current browser page and updates SDK state with the returned profile,
changes, and selected optimizations.
page() after SDK initialization for a multi-page application or first SPA route.trackCurrentPage() when a router integration needs route-key deduplication. Manual page()
calls always emit when consent permits them.{ accepted, data } result. { accepted: false } means consent or SDK
guards blocked the event.Copy this:
const result = await optimization.page()
Adapt this to your use case:
function getRouteKey(): string {
return `${window.location.pathname}${window.location.search}`
}
function buildCurrentPagePayload() {
const url = new URL(window.location.href)
return {
name: document.title,
properties: {
path: url.pathname,
query: Object.fromEntries(url.searchParams.entries()),
referrer: document.referrer,
search: url.search,
title: document.title,
url: url.toString(),
},
}
}
async function trackRoute(): Promise<void> {
await optimization.trackCurrentPage({
// Stable route keys prevent duplicate SPA page events.
routeKey: getRouteKey(),
buildPayload: buildCurrentPagePayload,
})
}
void trackRoute()
router.onRouteChange(() => {
void trackRoute()
})
Replace router.onRouteChange(...) with your framework or router hook. In hybrid apps where the
server already emitted the first page event, use initialPageEvent: 'skip' for the first browser
route.
Integration category: Required for first integration
The browser app fetches Contentful entries. The Web SDK chooses the current variant after the baseline entry and Experience API selections exist.
page() or identify() before rendering optimized content so SDK state has current
selectedOptimizations.resolveOptimizedEntry(). In a stateful Web SDK integration, the
method uses current SDK state when you omit the second argument.entry. If no matching optimization exists, the SDK returns the baseline
entry.Adapt this to your use case:
async function renderEntry(entryId: string, element: HTMLElement): Promise<void> {
const baselineEntry = await contentfulClient.getEntry(entryId, {
include: 10,
locale: APP_LOCALE,
})
// Omitted selections use current SDK state from the most recent accepted page or identify call.
const resolved = optimization.resolveOptimizedEntry(baselineEntry)
const { entry, optimizationContextId, selectedOptimization } = resolved
element.textContent = String(entry.fields.headline ?? '')
// Keep the baseline ID separate so rerenders do not resolve a variant as the baseline.
element.dataset.ctflBaselineId = baselineEntry.sys.id
element.dataset.ctflEntryId = entry.sys.id
if (optimizationContextId) {
element.dataset.ctflOptimizationContextId = optimizationContextId
} else {
delete element.dataset.ctflOptimizationContextId
}
if (selectedOptimization) {
element.dataset.ctflOptimizationId = selectedOptimization.experienceId
element.dataset.ctflSticky = String(selectedOptimization.sticky)
element.dataset.ctflVariantIndex = String(selectedOptimization.variantIndex)
} else {
delete element.dataset.ctflOptimizationId
delete element.dataset.ctflSticky
delete element.dataset.ctflVariantIndex
}
}
Entry resolution expects standard single-locale CDA fields such as fields.nt_experiences and
fields.nt_variants. All-locale CDA responses put field values under locale keys and cause
resolution to fall back to the baseline entry.
For deeper mechanics and fallback behavior, see Entry personalization and variant resolution.
Integration category: Common but policy-dependent
The Web SDK stores the most recent accepted profile, changes, selected optimizations, consent state, and diagnostic streams. Use state subscriptions for UI glue and rerenders.
states.selectedOptimizations when optimized entries need to rerender after
page(), identify(), or live state changes.states.profile for identity-aware UI and diagnostics.states.consent and states.persistenceConsent when a local consent UI needs SDK
state.states.eventStream and states.blockedEventStream for local diagnostics or
approved analytics forwarding.page() or identify() call when profile data must refresh.Adapt this to your use case:
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
// Rerender after page(), identify(), or live updates change the selected variants.
void renderVisibleEntries()
}),
optimization.states.blockedEventStream.subscribe((blockedEvent) => {
if (!blockedEvent) return
console.info(`Blocked Optimization event: ${blockedEvent.method}`)
}),
]
window.addEventListener('beforeunload', () => {
subscriptions.forEach((subscription) => subscription.unsubscribe())
})
Each observable immediately emits its current snapshot and then emits later updates. Use .current
for a synchronous read, for example optimization.states.profile.current.
Integration category: Common but policy-dependent
Call identify() when the browser session becomes associated with a known user. Call reset() when
the active browser identity must be discarded.
identify() after sign-in, account lookup, or persisted auth refresh when your consent and
identity policy permits the profile association.reset().page() when the app still needs browser-side optimization after reset.reset() clears SDK profile state and the SDK anonymous ID cookie, but it is not a CMP or server
cleanup API.Adapt this to your use case:
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> {
// reset() clears SDK profile state, not application-owned sessions or CMP records.
optimization.reset()
await optimization.page()
}
When persistence consent is true, the Web SDK can restore profile continuity from browser storage
and the readable ctfl-opt-aid anonymous ID cookie. When persistence consent is false or unset, the
SDK does not load durable profile continuity.
Integration category: Common but policy-dependent
Use entry interaction tracking when rendered Contentful entries need Analytics events for views,
clicks, or hovers. Use track() for custom business events.
views, clicks, and hovers to true; pass false for
interactions that must opt out.data-ctfl-entry-id on each auto-tracked entry element. Use the resolved entry ID, not
the baseline entry ID.data-ctfl-optimization-id, data-ctfl-optimization-context-id, data-ctfl-sticky, and
data-ctfl-variant-index when the resolved entry came from an optimization.<button> and <a href>, or mark a
non-semantic clickable path with data-ctfl-clickable="true".tracking.enableElement(...) when the DOM structure cannot use standard data-ctfl-*
attributes.track() for business events such as quote requests, form completions, or checkout
milestones.Copy this:
const optimization = new ContentfulOptimization({
clientId: APP_CONFIG.optimizationClientId,
// Opt out of interactions your consent and analytics policy does not permit.
autoTrackEntryInteraction: { hovers: false },
})
Adapt this to your use case:
<!-- Use the resolved entry ID, not the baseline entry ID, for automatic tracking. -->
<article
data-ctfl-entry-id="resolved-entry-id"
data-ctfl-optimization-id="experience-id"
data-ctfl-optimization-context-id="optimization-context-id"
data-ctfl-sticky="true"
data-ctfl-variant-index="1"
>
<button type="button">Request a quote</button>
</article>
Adapt this to your use case:
optimization.tracking.enableElement('views', element, {
// Manual data takes precedence over data-ctfl-* attributes for this element.
data: {
entryId: resolved.entry.sys.id,
optimizationContextId: resolved.optimizationContextId,
optimizationId: resolved.selectedOptimization?.experienceId,
sticky: resolved.selectedOptimization?.sticky,
variantIndex: resolved.selectedOptimization?.variantIndex,
},
dwellTimeMs: 1000,
})
await optimization.track({
event: 'quote_requested',
properties: {
plan: 'enterprise',
source: 'pricing-page',
},
})
Manual element data takes precedence over data-ctfl-* values on the same element. Use
tracking.disableElement(...) to force-disable one element, or tracking.clearElement(...) to
remove the manual override and fall back to attributes or global automatic tracking.
For thresholds, attribute precedence, DOM discovery, and delivery paths, see Interaction tracking in Web SDKs.
Integration category: Optional
Use merge tags when rendered Rich Text contains Contentful MergeTag entries. Use Custom Flags when an optimization response contains variable changes that control UI behavior.
Install the Rich Text renderer packages when your app does not already provide an equivalent Rich Text rendering path.
Copy this:
pnpm add @contentful/rich-text-html-renderer @contentful/rich-text-types
Resolve merge tags while rendering Rich Text. The Web SDK defaults to the current profile state when you omit the profile argument.
Keep the SDK Experience/event locale aligned with the Contentful CDA locale when localized profile values, such as location fields, need to match the rendered content language.
Read Custom Flags with getFlag(name) or states.flag(name).
Treat flag reads as Analytics exposure points. In stateful Web SDKs, flag reads auto-emit a flag view event when consent and profile state permit it, and repeated reads of the same value are deduplicated.
Adapt this to your use case:
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 ''
// Omit the profile argument to use the Web SDK's current profile state.
return optimization.getMergeTagValue(node.data.target) ?? ''
},
},
})
Adapt this to your use case:
const navigationFlag = 'new-navigation'
// Flag reads and subscriptions emit flag-view events when consent and profile state permit it.
document.body.dataset.newNavigation = String(optimization.getFlag(navigationFlag) === true)
optimization.states.flag(navigationFlag).subscribe((value) => {
document.body.dataset.newNavigation = String(value === true)
})
Integration category: Optional
The optional Web Components entrypoint provides vanilla custom elements for SDK ownership and entry resolution without a framework adapter.
defineContentfulOptimizationElements() from
@contentful/optimization-web/web-components.<ctfl-optimization-root> or
<ctfl-optimized-entry>.window.contentfulOptimization instance.defaults, api, trackEntryInteraction, sdk, and
baselineEntry as DOM properties, not string attributes.ctfl-entry-loading, ctfl-entry-resolved, and ctfl-entry-error to render
application-owned UI.Adapt this to your use case:
import {
type ContentfulOptimizationRootElement,
type ContentfulOptimizedEntryElement,
type ContentfulOptimizedEntryEventDetail,
defineContentfulOptimizationElements,
} from '@contentful/optimization-web/web-components'
defineContentfulOptimizationElements()
const root = document.querySelector<ContentfulOptimizationRootElement>('ctfl-optimization-root')
const entry = document.querySelector<ContentfulOptimizedEntryElement>(
'ctfl-optimized-entry[data-entry-id]',
)
if (root) {
// Structured SDK options must be assigned as properties, not string attributes.
root.defaults = { consent: true }
root.trackEntryInteraction = { hovers: false }
}
if (entry?.dataset.entryId) {
const baselineEntry = await contentfulClient.getEntry(entry.dataset.entryId, {
include: 10,
locale: APP_LOCALE,
})
// The SDK resolves after app code supplies the structured baseline entry object.
entry.baselineEntry = baselineEntry
entry.addEventListener('ctfl-entry-resolved', (event) => {
const { detail } = event as CustomEvent<ContentfulOptimizedEntryEventDetail>
renderHero(detail.entry)
})
}
Follow this pattern:
<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>
The data-entry-id attribute above is app-owned lookup metadata, not SDK fetch configuration.
@contentful/optimization-web/web-components is side-effect-free. Custom elements are registered
only when defineContentfulOptimizationElements() runs. If the root owns the SDK instance,
trackEntryInteraction defaults view, click, and hover tracking to enabled. The lower-level
ContentfulOptimization constructor uses the same automatic interaction defaults.
Use live-updates on the root or an optimized entry only when a rendered entry needs to respond to
later selected-optimization changes instead of keeping its first resolved value.
Integration category: Optional
The preview panel is a separate browser package for development, preview, and staging workflows. It
attaches a Lit-based panel to document.body, uses an existing Contentful Delivery API client to
read preview content, and talks to an existing Web SDK instance through the browser preview bridge.
Install @contentful/optimization-web-preview-panel only when your app needs browser authoring
tooling.
Copy this:
pnpm add @contentful/optimization-web-preview-panel
Gate the dynamic import behind an environment value so production bundles can remove preview code
when the gate is replaced with false at build time.
Attach the panel after the Web SDK singleton and Contentful client exist. The attach function
uses window.contentfulOptimization by default.
Pass the optimization option when your app owns an SDK instance that is not available through
window.contentfulOptimization.
Pass a CSP nonce when strict Content Security Policy rules require one for Lit styles.
Expect SDK-controlled optimized entries to live-update while the panel drawer is open. Manual
renderers still need states.selectedOptimizations subscriptions if they must react to preview
overrides.
Adapt this to your use case:
function attachPreviewPanel(): void {
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,
// Omit this when the preview panel can use window.contentfulOptimization.
optimization,
nonce: APP_CONFIG.cspNonce,
})
})
.catch((error: unknown) => {
console.warn('Failed to attach the Contentful Optimization preview panel.', error)
})
}
attachPreviewPanel()
The attach function is side-effect-free until called. Repeated calls reuse the in-flight or
completed attachment. While the panel is open, Web Components entry rendering treats live updates as
enabled so preview overrides can render without toggling live-updates on the root or entry.
Integration category: Optional
Use this integration when your browser app already sends events to a tag manager, customer data platform, or analytics destination. The Optimization SDK still sends its own events to Contentful. Your application decides which approved Contentful context, if any, can also be forwarded.
states.eventStream subscription after SDK initialization.messageId so current snapshots, subscriber remounts, retries,
or duplicate browser deliveries do not resend the same SDK event record.messageId before
subscribing and skip that event.viewId, componentId, experienceId, and variantIndex.states.blockedEventStream and destination debuggers to validate consent behavior.In this example, canForwardSdkEvent() enforces your governance and consent allow-list,
shouldForwardContentfulEvent() applies destination-specific semantic dedupe, and
pickContentfulEventProperties() maps only approved fields.
Follow this pattern:
const forwardedMessageIds = new Set<string>()
const initialMessageId = optimization.states.eventStream.current?.messageId
// eventStream is live; register the subscription once during SDK initialization.
const analyticsSubscription = optimization.states.eventStream.subscribe((event) => {
if (!event) return
if (forwardedMessageIds.has(event.messageId)) return
if (event.messageId === initialMessageId) {
forwardedMessageIds.add(event.messageId)
return
}
if (!canForwardSdkEvent(event)) return
forwardedMessageIds.add(event.messageId)
if (!shouldForwardContentfulEvent(event)) return
analytics.track(`Contentful ${event.type}`, pickContentfulEventProperties(event))
})
window.addEventListener('beforeunload', () => {
analyticsSubscription.unsubscribe()
})
For destination mappings, helper examples, consent alignment, identity, dedupe, and governance, see Forwarding Optimization SDK context to analytics and tag-management tools.
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.
ANONYMOUS_ID_COOKIE value when consent
permits durable profile continuity.path: '/' and a same-site policy that matches your app.HttpOnly if browser code must read it.trackCurrentPage({ initialPageEvent: 'skip', ... }) when the server already emitted the
same initial page event and the browser takes over subsequent route events.Follow this pattern:
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.
return `${ANONYMOUS_ID_COOKIE}=${id}; Path=/; SameSite=Lax`
}
For the lower-level mechanics, see Profile synchronization between client and server.
Before release, verify these behaviors in the target deployment:
defaults: { consent: true } only when
application policy permits it. 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.page(), identify(), track(), entry views, clicks, hovers, and Custom
Flag views are accepted or blocked exactly as the policy expects. states.blockedEventStream
stays empty for expected allowed events.messageId dedupe is applied before forwarding exact analytics records,
semantic exposure dedupe is applied when a destination wants one sticky-view exposure, and element
tracking is not enabled twice for the same DOM node.Use these checks when the browser integration does not behave as expected:
| Symptom | Likely cause | Check |
|---|---|---|
ContentfulOptimization is already initialized |
More than one Web SDK instance exists in the same browser runtime | Reuse the module singleton or call destroy() only in teardown paths |
track() or interaction events return or 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 |
resolveOptimizedEntry() always returns the baseline |
The app has no selected optimizations, the entry is not optimized, links are unresolved, or CDA data is all-locale | Verify the preceding page() or identify() result, CDA include, locale, fields.nt_experiences, and variant links |
| SPA page events duplicate | Route changes use direct page() calls without route-key dedupe |
Use trackCurrentPage() with a stable route key |
| 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 flag value changes |
| Hybrid browser sessions start with a different anonymous profile | The server and browser do not share the same readable anonymous ID cookie | Verify ctfl-opt-aid path, same-site settings, consent state, and whether the cookie is readable by browser code |
Use these repository examples when you want a working implementation to compare with guide snippets:
page(), entry resolution,
merge tags, live updates, and automatic or manual entry interaction tracking.Use the Web SDK package README for package orientation and common configuration, and the generated Web SDK reference for exhaustive API signatures.