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.
page() on first load and route changesselectedOptimizationsstates for rerenders and UI feedbackThe Web SDK is the browser-side package in the Optimization SDK Suite. It lets consumers:
page(), identify(), and track() and receive profile data,
selected optimizations, and Custom Flag changesThe 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:
defaults: { consent: true } for default-on
integrations, or call consent(true | false) from a consent UI or CMP callback.page() on the first load and again whenever the active route changes.resolveOptimizedEntry().identify() when the user becomes known, and reset() when identity must be discarded.track(),
trackView(), trackClick(), or trackHover().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:
PUBLIC_... environment variable names. A consumer app can use
any runtime-config mechanism that fits its bundler or deployment setup.web-sdk_react
implementation demonstrates that pattern even though its rendering layer is React.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
trackersconsent(false) keeps the browser in a denied state and blocks non-allowed event typesreset() is not a consent API; it clears profile-related state but intentionally preserves the
consent choiceBy 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 }).
page() on first load and route changesIn 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.
selectedOptimizationsOnce 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:
page() or identify().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()
})
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.
The Web SDK can emit more than page and identify events. Common browser-side cases are:
view, click, and hover tracking from the DOMtrackView() calls for UI regions that are not directly tied to a Contentful entrytrack() calls for business events such as quote requests or sign-up milestonestrackClick() and trackHover() calls when the app has custom interaction logic that must not
rely on DOM auto-detectionIf 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',
},
})
states for rerenders and UI feedbackThe 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 UIstates.profile for identity-aware UIstates.selectedOptimizations for rerendering optimized entriesstates.flag(name) for feature flag gatesstates.eventStream for analytics debugging or local dev toolingstates.blockedEventStream for consent-gating diagnosticsExample:
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:
ANONYMOUS_ID_COOKIE with path: '/' and sameSite: 'lax' when consent
permits profile continuityIf 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:
page(), entry resolution, merge tags, and automatic or manual interaction tracking.page() emission,
consent updates, identify(), reset() patterns, resolved-entry rendering, and automatic and
manual tracking metadata.