Integrate the Optimization Web SDK in a web app
Table of contents
- Overview
- Scope and capabilities
- The integration flow
- 1. Install and initialize the SDK
- 2. Handle consent in the UI layer
- 3. Emit
page()on first load and route changes - 4. Resolve Contentful entries with
selectedOptimizations - 5. Resolve merge tags and custom flags
- 6. Identify known users and reset when identity changes
- 7. Track entry interactions and follow-up events
- 8. Subscribe to
statesfor rerenders and UI feedback - Share the anonymous ID cookie in hybrid SSR + browser apps
- Reference implementations to compare against
Overview
This guide helps you implement client-side personalization and analytics in a browser application, such as a static site, multi-page app, SPA, or custom frontend runtime, 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.
Scope and capabilities
The Web SDK is the browser-side package in the Optimization SDK Suite. It lets consumers:
- Evaluate browser events such as
page(),identify(), andtrack()and receive profile data, selected optimizations, and Custom Flag changes - Persist consent, profile state, selected optimizations, 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.
The integration flow
In practice, most Web SDK integrations follow this high-level sequence:
- Create one SDK instance for the current page or SPA runtime.
- Let the application own consent UI and call
consent(true | false)when the user makes a choice. - Emit
page()on the first load and again whenever the active route changes. - Fetch baseline Contentful entries and resolve variants with
resolveOptimizedEntry(). - Render flags and merge tags from current SDK state.
- Call
identify()when the user becomes known, andreset()when identity must be discarded. - Enable automatic or manual entry tracking and send follow-up business events with
track(),trackView(),trackClick(), ortrackHover(). - Subscribe to
statesso the UI rerenders when profile or optimization state changes.
The Web-focused reference implementations in this repository show that pattern in working applications:
1. Install and initialize the SDK
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
export const contentfulClient = 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,
app: {
name: 'my-web-app',
version: '1.0.0',
},
api: {
experienceBaseUrl: APP_CONFIG.experienceBaseUrl,
insightsBaseUrl: APP_CONFIG.insightsBaseUrl,
},
autoTrackEntryInteraction: { views: true, clicks: true, hovers: true },
logLevel: 'warn',
})
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_reactimplementation 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.
2. Handle consent in the UI layer
The Web SDK exposes a browser-side consent() method, but your application still owns the consent
policy and user experience.
By default, only identify and page are allowed before consent is explicitly set. Other event
types are blocked until the user accepts consent. When consent is accepted, the Web SDK also starts
any auto-enabled entry interaction trackers.
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 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
If your policy requires a stricter pre-consent posture, configure allowedEventTypes: [] during initialization instead of relying on the default ['identify', 'page'].
3. Emit page() on first load and route changes
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.
4. Resolve Contentful entries with selectedOptimizations
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:
- Ask Optimization for the current profile's selected variants by calling
page()oridentify(). - Fetch the baseline Contentful entry.
- Resolve the optimized entry variant before rendering it into the DOM.
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()
})
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.
5. Resolve merge tags and custom flags
The Web SDK also exposes helpers for profile-aware merge tags and Custom Flags.
Merge tags
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.
Custom flags
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.
6. Identify known users and reset when identity changes
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, andhovertracking 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 milestonestrackClick()andtrackHover()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.
Manual element observation
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.
Browser tracking mechanics
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.
Custom browser events
Use track() for business events:
await optimization.track({
event: 'quote_requested',
properties: {
plan: 'enterprise',
source: 'pricing-page',
},
})
8. Subscribe to states for rerenders and UI feedback
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.consentfor consent UIstates.profilefor identity-aware UIstates.selectedOptimizationsfor rerendering optimized entriesstates.flag(name)for feature flag gatesstates.eventStreamfor analytics debugging or local dev toolingstates.blockedEventStreamfor 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.
Share the anonymous ID cookie in hybrid SSR + browser apps
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_COOKIEwithpath: '/'andsameSite: 'lax' - The browser Web SDK reads the same cookie during initialization
- After hydration, browser events continue from the same anonymous profile instead of starting over
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.
Reference implementations to compare against
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.