Integrate the Optimization Web SDK in a web app
Overview
Use this guide to add Contentful personalization to a browser app you already have that is not built with React — a static site, a multi-page app, a single-page app, or a custom frontend runtime where you want to own the browser SDK lifecycle directly. By the end of the quick start, one piece of content will render its personalized variant in the page once you resolve it, without changing how your app fetches or renders content.
New to personalization? Here is the whole idea in four points:
- In Contentful you author variants of an entry and attach them to an experience — a rule that decides which visitors see which variant.
- As the visitor uses your app, Contentful’s Experience API looks at who they are and picks the variant for each experience. Swapping a fetched entry for its picked variant is called resolving the entry.
- Your app hands a Contentful entry to the SDK at the point where that entry becomes output. The SDK gives back the selected variant, or the original entry when no variant applies—the baseline fallback. You can fetch the entry yourself or give the SDK your Contentful client and an entry ID; either way, the client stays yours.
- You render the returned entry with the same application components you already use.
That is enough to start. The guide introduces policy and optional capabilities at the point you need them.
You will get there in two milestones:
- Milestone 1 — a personalized entry rendered into the page (the quick start below). After you emit a page event, fetch an entry, and resolve it, a visitor sees their variant on screen. This is complete and shippable on its own.
- Milestone 2 — live re-personalization (opt-in, later). Content re-resolves when consent, identity, or profile changes, without a full reload, by subscribing to SDK state and re-rendering. See State subscriptions, locale changes, and re-rendering.
This guide uses the ContentfulOptimization class from @contentful/optimization-web. You create
one instance, drive it imperatively — emit events, resolve entries, subscribe to state — and your
app keeps ownership of its Contentful client, consent policy, identity, routing, caching, and
rendering. The package also ships optional Web Components (defineContentfulOptimizationElements())
for a declarative element-based integration; the quick start uses the class, and
Web Components entry rendering covers the elements.
If you are building a React app and want official providers, hooks, components, and router adapters, use the React Web SDK guide instead. If your app renders on the server with Next.js, use the Next.js App Router guide or the Next.js Pages Router guide.
Quick start
Most browser + Contentful apps share one shape: you fetch an entry (a page, a hero, a section) and render its fields into the DOM. This quick start assumes that shape and personalizes a single entry. If your app is shaped differently, the change is the same wherever an entry becomes rendered markup; see Resolving entries and rendering the result.
It proves one result: one entry renders its personalized variant in the page once the SDK resolves it. This quick start assumes your app may personalize on startup; if personalization must wait for consent, keep this structure and add the Consent and privacy handoff step before you ship.
-
Install the browser SDK and a Contentful delivery client. Add
contentfulonly if your app does not already have a Contentful Delivery API client.Copy this:
-
Create one SDK instance for the page or single-page app (SPA) runtime, emit one
page()event, fetch one single-locale entry, resolve it, and render the result into the DOM. Read the placeholder config from whatever mechanism your build uses to expose browser-visible values, and keep it consistent with the Contentful variables your app already ships.defaults: { consent: true }tells the SDK it may personalize and send events for this visitor; the quick start uses always-on consent to keep the path simple — production gates this on the visitor’s real choice (see Consent and privacy handoff).Before you resolve, call
page()once: a page event asks the Experience API who this visitor is and returns their current variant selections, so the SDK has optimization state to use when you resolve the entry immediately after.Adapt this to your use case: replace the placeholder values and the
#heroselector with your own; the config keys are explained in How the SDK fits your app. The render step is a minimal placeholder — substitute your own field rendering, template, or DOM update. -
Check that it works. In Contentful, author a variant on the entry you fetch above and attach it to an experience — for a first test, target all visitors so you match it automatically. Load the page: the hero renders the variant’s text. If the baseline text stays on screen instead, work through Troubleshooting.
You now have personalization working. The rest of this guide is not a re-run of the quick start — it explains what each step did and covers what the quick start deliberately skipped: real, consent-gated startup; the create-emit-resolve lifecycle; your Contentful fetch requirements and the baseline-fallback contract; page and route events; state subscriptions and live re-rendering; interaction tracking; identity; Web Components; and production hardening. Read straight through, or jump to the section you need.
Before you start
The sections below walk the integration in order. First, gather the few things you can only get from outside this guide:
- A browser app with a build or runtime that can load an npm package, and its own Contentful
fetching already working.
contentfulis a companion dependency you install alongside the SDK if you do not already have a Delivery API client. - Contentful delivery credentials — space ID, delivery token, and environment.
- At least one entry with a variant attached to an experience, authored in Contentful. Without an authored variant, the integration can still run correctly while returning the baseline, so you cannot yet distinguish working personalization from a content-authoring gap. For the first personalized-content test, target all visitors so the test request or visitor matches automatically.
- Your Optimization project values — client ID and environment, from your Optimization project settings. The Experience and Insights API base URLs default correctly; you only set them for mocks or non-default hosts (see How the SDK fits your app).
You do not need a setup inventory up front. Everything else — consent, page events, state subscriptions, tracking, identity — is introduced by the section that needs it.
The Web SDK is bundler-agnostic. Read its config from whatever mechanism your build uses to expose browser-visible values (a bundler define, import.meta.env, a server-injected global, or plain constants), and keep it consistent with your other browser-visible Contentful variables. Ship only the Contentful delivery token to the browser, never a Management API token.
Core integration
How the SDK fits your app
Integration category: Required for first integration
This section explains the ContentfulOptimization instance you created in the quick start — what
each config key does and how to make startup depend on real consent.
The Web SDK is a thin, stateful layer between three things you already have or control: your Contentful data, Contentful’s Experience API, and your rendering code. You create one instance and reuse it across route handlers, render code, and interaction handlers. It is not a Contentful client replacement: the Contentful client and credentials are yours, along with routing, rendering, consent policy, identity policy, and cache policy.
The config you pass to new ContentfulOptimization(...) breaks down like this:
clientIdandenvironmentidentify your Optimization project. Read them from browser-safe config.localeis the one locale the SDK uses for Experience and event context. Use the same locale you pass to Contentful.apioverrides the Experience and Insights endpoints (experienceBaseUrl,insightsBaseUrl). Set these only for a mock, a proxy, or non-default hosts; omit them otherwise.defaultsis the SDK’s starting state:consent(may personalize and send events) andpersistenceConsent(may store the profile-id cookie — the anonymous identifier the SDK assigns each visitor to keep their variant assignments consistent across visits). If you setconsentbut omitpersistenceConsent,persistenceConsentdefaults to yourconsentvalue.appis your app’s name and version, sent as event metadata.logLevel,allowedEventTypes,autoTrackEntryInteraction,cookie,queuePolicy, andonEventBlockedare optional and covered in their own sections below.
Keep the instance in a module-level binding or another singleton container. In a browser the
constructor attaches the instance to window.contentfulOptimization and throws
ContentfulOptimization is already initialized if one already exists there. Call destroy() only
for explicit teardown paths such as tests, hot reload, or a framework root unmount that owns the
instance.
The quick start used always-on defaults to get you a result. For production, make startup depend
on real consent: leave consent unset (or seed it off) and call consent(true) from the UI that
owns the visitor’s decision, as shown in
Consent and privacy handoff.
Adapt this to your use case: the shared module a real app imports everywhere, with app metadata and API overrides.
For the locale model, see Locale handling in the Optimization SDK Suite.
The SDK lifecycle: create, emit, resolve
Integration category: Required for first integration
This is the concept that has no equivalent in the component-based guides, so it is worth stating plainly. The Web SDK is imperative and stateful, and its state fills in a specific order:
- The instance is ready synchronously.
resolveOptimizedEntry(),getFlag(), and thestates.*observables (explained in State subscriptions, locale changes, and re-rendering) work the moment you callnew ContentfulOptimization(...). - But optimization state is empty until an accepted event returns it. The SDK only has current
selectedOptimizationsafter an acceptedpage()oridentify()call resolves (identify()works the same way — see Identity, profile, and reset). Resolve an entry before that and you get the baseline — which is correct, just not personalized yet.
So the order that matters is: construct → emit page() (or identify()) → resolve entries. That is
why the quick start awaits page() before calling resolveOptimizedEntry().
- Construct the instance once and reuse it (see How the SDK fits your app).
- Emit an accepted
page()oridentify()before rendering optimized content so SDK state carries currentselectedOptimizations. - Resolve and render entries. When you omit the second argument,
resolveOptimizedEntry()uses the SDK’s current state, so later re-renders pick up the latest selections automatically.
Follow this pattern: the ordered startup sequence.
Fetching Contentful entries
Integration category: Required for first integration
The Contentful client is yours. This is the boundary, and it has two supported shapes: you fetch, the SDK resolves, or you hand the SDK your client and it fetches by ID for you. Both end at the same resolution step, and you can use different paths for different entries in the same app.
- Manual — you fetch the entry with your own client and pass it in. The quick start uses this path. Keep your existing client, fetchers, and caching; the SDK only needs entries to arrive in a shape it can resolve.
- Managed — you give the SDK your Contentful client once through the
contentfulconfig key, and it fetches by entry ID through that client whenever you callfetchContentfulEntry(id)orfetchOptimizedEntry(id). The client stays yours; the SDK uses itsgetEntry()andgetEntries()methods.
Either way, the same fetch requirements hold:
- Fetch with one concrete Contentful locale. Do not use
withAllLocalesor raw Contentful Delivery API (CDA)locale=*— all-locale payloads use locale-keyed field maps the resolver cannot read, so entries fall back to baseline. - Use an
includedepth deep enough to resolve the whole tree — the entry, its sections, and the linked variant entries.include: 10is the common setting. - Use the same locale for Contentful and for the SDK so localized Experience responses and rendered content line up.
A single-locale entry exposes its optimization fields directly, such as fields.nt_experiences and
fields.nt_variants (the nt_ prefix is how personalization links appear on an entry).
Adapt this to your use case: the manual path — your own fetcher, which the render step then
resolves. fetchEntry is a helper you own and name.
For the managed path, pass your client to the SDK as contentful: { client }. The SDK then merges
your contentful.defaultQuery, any per-call query, the SDK locale as a fallback, and include: 10
into each getEntry() call, and caches results per instance (default
{ maxEntries: 100, ttlMs: 300_000 }; pass cache: false to disable, or
clearContentfulEntryCache() to clear it). fetchContentfulEntry(id) returns the fetched entry;
fetchContentfulEntries(entries) preserves descriptor order and uses getEntries() for multiple
uncached entries with the same normalized query, split into 100-ID chunks for large fetches.
prefetchManagedEntries(entries) returns server handoff objects for framework adapters.
fetchOptimizedEntry(id) fetches and resolves in one call (see
Resolving entries and rendering the result).
Adapt this to your use case: the managed path — configure the client once, then fetch by ID.
For the combined fetch-and-resolve call — fetchOptimizedEntry(id), which fetches and resolves in
one step — see the next section. For the resolver contract, see
Entry personalization and variant resolution.
Resolving entries and rendering the result
Integration category: Required for first integration
The quick start showed the resolve-and-render. This explains the return shape and the two things about it that matter everywhere. The rule never changes: wherever a Contentful entry becomes rendered markup, resolve it first and render whatever the SDK hands back.
resolveOptimizedEntry(baselineEntry, selectedOptimizations?) returns an object:
entry— the resolved variant when one applies, or the baseline entry otherwise. This is what you render.selectedOptimization— selection metadata (experienceId,variantIndex,sticky,variants). It isundefinedonly when no experience matched (no selections, the entry is not optimized, or no selection matched it). When an experience matches but assigns the visitor to the control/baseline variant, it is defined withvariantIndex: 0— and the returnedentrystill equals the baseline. So do not readselectedOptimization === undefinedas “the visitor is seeing baseline content.”optimizationContextId— an opaque id you attach to the rendered element so interaction tracking can tie events back to this selection (see Entry interaction tracking).
Omit the second argument to resolve against the SDK’s current state (the selections from the most
recent accepted page()/identify()); pass an explicit SelectedOptimizationArray only when you
resolve against selections you captured yourself — for example selections handed over from a
server-rendered response (see
Hybrid Node SSR and browser continuity).
If you configured the managed path (contentful: { client }), fetchOptimizedEntry(id, options?)
fetches and resolves in one call and returns the same fields plus the baselineEntry it fetched.
Use it when you want the SDK to own the fetch; use resolveOptimizedEntry(entry) when you fetch the
entry yourself.
Follow this pattern: managed fetch-and-resolve in one call.
Two facts hold everywhere:
- The resolved entry is a base
contentfulEntry.entry.fieldsis typed loosely, so if your render code expects a narrower type, cast it —entry as YourEntryType. This direct cast works for the common cases, including.withoutUnresolvableLinks-narrowed types. Only if TypeScript rejects a cast for a genuinely disjoint type do you needentry as unknown as YourEntryType. - Fallback contract. When consent is denied, no variant applies, links are unresolved, or the
payload was all-locale,
resolveOptimizedEntry()returns the baseline entry. Your UI never breaks; it falls back to default content — this is why the quick start renders correctly even before you author a variant.
Keep the baseline entry id separate from the resolved entry id in the DOM. Later re-renders read the baseline id to resolve again, so overwriting it with the variant id would make the SDK treat a variant as the baseline.
Adapt this to your use case: a render function that resolves one manually fetched entry and writes it plus its tracking metadata into an element.
Page and route events
Integration category: Required for first integration
A page event signals that a page or route was viewed. The Experience API uses page events to evaluate route-based experiences and to return current selections, so most integrations emit one on first load and on every route change.
- Call
page()after SDK initialization for a multi-page app or the first SPA route. It returns{ accepted, data };{ accepted: false }means consent or an SDK guard blocked the event. - In SPAs, use
trackCurrentPage({ routeKey, buildPayload })on route changes. It deduplicates consecutive identical route keys (a manualpage()always emits when consent permits it). - Include stable page properties — url, path, search, referrer, title — when your router or analytics taxonomy needs them.
- In hybrid apps where the server already emitted the first page event, pass
initialPageEvent: 'skip'totrackCurrentPagefor the first browser route so the browser does not report a duplicate (see Hybrid Node SSR and browser continuity).
Copy this:
Adapt this to your use case: an SPA route tracker with stable route keys, wired to your router.
Consent and privacy handoff
Integration category: Common but policy-dependent
Consent policy belongs to your application. The SDK tracks two independent axes: consent (may
personalize and send events) and persistenceConsent (may store the profile-id cookie). While
event consent is undefined or false, the SDK’s default allow-list permits only identify and
page; other events stay blocked.
- If policy permits personalization by default and you render no consent UI, seed accepted consent
in
defaults(as the quick start does). - If policy depends on user choice, leave
consentunset and callconsent(true | false)from the banner, consent-management platform (CMP) callback, or settings screen that owns the decision. - For strict opt-in, pass
allowedEventTypes: []so no event can emit before an explicit choice. - Use object-form consent —
consent({ events: true, persistence: false })— only when events are permitted but durable profile continuity must stay session-only. A boolean sets both axes together. - Persist the visitor’s choice in your own store (a cookie,
localStorage, or account preference) so your UI can restore it next visit. That consent record is yours — you name, write, and read it. The SDK does not manage it; it only reflects what you pass toconsent().
Follow this pattern: default-on, when policy permits.
Follow this pattern: strict opt-in — no event emits until the visitor accepts.
Adapt this to your use case: a consent control wired to the SDK and to your own consent record.
The SDK stores its own consent, persistence-consent, and profile-continuity state in localStorage;
the one persistence value it owns and exposes as a cookie is the browser-readable profile-id cookie
ctfl-opt-aid. Calling consent(false) blocks subsequent non-allowed events and clears SDK-managed
durable storage, but it does not erase your app, server, or CMP records, and it does not drop the
active in-memory profile — call reset() for that (see
Identity, profile, and reset). For the cross-SDK policy model, see
Consent management in the Optimization SDK Suite.
State subscriptions, locale changes, and re-rendering
Integration category: Common but policy-dependent
This is Milestone 2. First render is already complete and shippable; add re-rendering only when some content must re-personalize after it first resolves — for example when a visitor accepts consent, signs in, or is identified, and entries should update without a reload. Because the Web SDK is imperative, you get this by subscribing to state and re-running your own render, rather than a framework doing it for you.
The SDK exposes its latest accepted profile, selected optimizations, consent state, and diagnostic
streams through states.*. Every observable emits its current value immediately on subscribe and
then emits later updates; read .current for a one-off synchronous read.
- Subscribe to
states.selectedOptimizationswhen optimized entries must re-render afterpage(),identify(), or a profile change. Re-run the same resolve-and-render you used at first paint. - Subscribe to
states.profilefor identity-aware UI, andstates.consent/states.persistenceConsentwhen a local consent UI must reflect SDK state. - Subscribe to
states.eventStreamandstates.blockedEventStreamfor diagnostics or approved analytics forwarding (see Analytics forwarding). - Unsubscribe when the page root, framework root, or long-lived view tears down.
- When the app locale changes, call
setLocale(nextLocale), then refetch Contentful entries with the new CDA locale and emit a freshpage()oridentify().setLocaleupdates subsequent Experience API requests and event context only; it does not refetch entries or clear your caches.
Adapt this to your use case: subscribe once, re-render on selection changes, and clean up.
To verify, accept consent or call identify(), then confirm your subscribed render swaps the
affected entries to their variants without a full reload.
Entry interaction tracking
Integration category: Common but policy-dependent
Interaction tracking — views, clicks, and hovers on entries — is a browser behavior. The SDK
observes any element in the DOM carrying the data-ctfl-* tracking attributes, and emits the
matching events once consent permits. Automatic tracking for all three interaction types is on by
default, so you rarely configure anything to get started.
- Render
data-ctfl-entry-idon each tracked element using the resolved entry id, not the baseline id. The resolve-and-render helper already writes it, alongsidedata-ctfl-optimization-id,data-ctfl-optimization-context-id, anddata-ctfl-variant-indexwhen the entry came from an optimization. - Leave the defaults on when your consent policy allows them. Use the constructor’s
autoTrackEntryInteractiononly to opt out of an interaction type you must not observe. - For click tracking, use semantic clickable elements (
<button>,<a href>) or mark a non-semantic clickable path withdata-ctfl-clickable="true". - Use
tracking.enableElement(...)for DOM the attribute path cannot express, andtrack()for custom business events (quote requests, form completions, checkout milestones). - Page and identify events can be sent before full consent, but entry views, clicks, and hovers
stay blocked until consent (or
allowedEventTypes) permits them.
Follow this pattern: opt one detector out globally.
Adapt this to your use case: enable manual element tracking and emit a business event.
Use tracking.disableElement(...) to force-disable one element, or tracking.clearElement(...) to
remove a manual override so recycled DOM nodes do not keep stale entry data. For thresholds,
attribute precedence, and delivery paths, see
Interaction tracking in Web SDKs.
Identity, profile, and reset
Integration category: Common but policy-dependent
Identify a visitor only when your app knows who they are or has policy-approved traits to send. Reset profile state when the active visitor changes or logs out.
- Call
identify({ userId, traits })after sign-in, account lookup, or persisted auth refresh when your consent and identity policy permits the association.userIdis required. - Call
reset()on logout, account switch, or consent withdrawal that ends profile continuity. It clears SDK profile state, selected optimizations, route dedupe, and thectfl-opt-aidcookie — but not your own sessions, cookies, or CMP records; clear those separately. - Emit another
page()after a reset when the app still needs browser-side optimization.
Adapt this to your use case: login and logout handlers wired to the SDK actions.
When persistence consent is true, the SDK can restore profile continuity from localStorage and the
readable ctfl-opt-aid cookie; when it is false or unset, it does not load durable continuity. For
cross-runtime identity behavior, see
Profile synchronization between client and server.
Optional integrations
Web Components entry rendering
Integration category: Optional
The optional Web Components entrypoint provides vanilla custom elements so you can resolve and
render entries declaratively in markup instead of driving resolveOptimizedEntry() by hand. It is
side-effect-free until you register the elements.
-
Import and call
defineContentfulOptimizationElements()once from@contentful/optimization-web/web-componentsbefore using the elements. It registers<ctfl-optimization-root>and<ctfl-optimized-entry>. -
Use one
<ctfl-optimization-root>for entries that share one SDK instance. The root creates the SDK from its attributes and assigned properties, reuses an existingwindow.contentfulOptimizationinstance automatically if one is already present, and lets you pass an explicit instance by assigning itssdkproperty. -
Pass simple config as attributes (
client-id,environment,locale,live-updates), and structured config as DOM properties (defaults,api,trackEntryInteraction,sdk,onStatesReady) — attributes are strings, so objects must be assigned as properties. -
Give each
<ctfl-optimized-entry>its entry one of two ways:- Manual: assign the fetched entry to the
baselineEntryproperty (an object, so not an attribute). You fetch the entry yourself and the element resolves it. - Managed: set the SDK-owned
entry-idattribute (or theentryIdproperty, plus an optionalentryQueryproperty) and the element fetches and resolves by ID for you — no manual fetch. This works only when the shared SDK instance carries a Contentful client (contentful: { client }), so use a reusedwindow.contentfulOptimizationor an assignedsdkthat was configured that way; a root that builds its SDK fromclient-id/environment/localealone has no client to fetch through.
Per-entry tracking overrides use the
track-views,track-clicks,track-hovers, andlive-updatesattributes either way. - Manual: assign the fetched entry to the
-
Listen for
ctfl-entry-loading,ctfl-entry-resolved, andctfl-entry-erroron an entry element to render app-owned UI; the root emitsctfl-root-readyandctfl-root-error.
The data-entry-id below is an example name you invent — the SDK does not read it. The SDK-owned
attribute is entry-id (no data- prefix), shown in the managed example below. Keep the two
distinct.
Adapt this to your use case: the manual path — you fetch and assign baselineEntry, then render
on resolve. Here data-entry-id is your own lookup key, not the SDK’s entry-id attribute.
Follow this pattern: the markup the manual script above drives. data-entry-id is the app’s own
attribute; the script reads it to decide what to fetch.
For the managed path, the SDK instance must carry a Contentful client, and the element takes the
SDK-owned entry-id attribute (no data- prefix). Setting entry-id makes the element fetch and
resolve by ID on its own — you write no fetch and assign no baselineEntry.
Follow this pattern: managed markup — the SDK’s own entry-id attribute drives the fetch.
The entry-id attribute is SDK-owned: match the exact name and the element fetches through the
configured client. The data-entry-id in the manual example is a reader-invented lookup key — the
app names it and reads it to drive its own fetch. Do not treat one as the other.
When the root owns the SDK instance, trackEntryInteraction defaults view, click, and hover
tracking to enabled — the same defaults as the ContentfulOptimization constructor. Set the
live-updates attribute on the root or an entry only when a rendered entry must re-resolve on later
selection changes instead of keeping its first resolved value.
Merge tags and Custom Flags
Integration category: Optional
Use merge tags when rendered Rich Text contains Contentful MergeTag entries (a personalized greeting, a location). Use Custom Flags when app behavior branches on a named flag rather than an optimized entry.
- Install a Rich Text renderer if your app does not already have one.
- Resolve merge tags while rendering Rich Text: for each embedded entry node, guard with
isMergeTagEntry(from@contentful/optimization-web/api-schemas) and pass the node’stargettogetMergeTagValue. Omit the profile argument to use the SDK’s current profile state. - Keep the SDK locale aligned with the rendered Contentful locale when merge tags reference
localized profile fields such as
location.cityorlocation.country. - Read Custom Flags with
getFlag(name)for a one-off read, or subscribe tostates.flag(name)when UI must re-render as the flag changes. Reading a flag emits a flag-view event when consent and profile state permit it, and repeated reads of the same value are deduplicated.
Copy this: Install the Rich Text renderer.
Adapt this to your use case: resolve merge tags while rendering Rich Text.
Adapt this to your use case: read and subscribe to a Custom Flag.
Merge tags and entry replacement use different mechanics: entry replacement swaps the whole entry for its variant; merge tags read profile-backed values from current SDK state.
Analytics forwarding
Integration category: Optional
Use analytics forwarding when your app already sends events to a tag manager, customer-data platform, or analytics destination. The SDK still sends its own events to Contentful; forwarding is application-owned, and your app decides which approved Contentful context, if any, is also forwarded.
- Register one app-level
states.eventStreamsubscription after SDK initialization. - Dedupe forwarded records by
messageId. To receive only future events, read the currentmessageIdbefore subscribing and skip it. - Forward only events and fields your governance policy approves, gated by the same consent and destination policy that governs the rest of your analytics stack.
- Use
states.blockedEventStreamand destination debuggers to validate consent behavior.
Follow this pattern:
For destination mappings, consent alignment, dedupe, and governance, see Forwarding Optimization SDK context to analytics and tag-management tools.
Preview panel
Integration category: Optional
The preview panel is a separate browser package for development, preview, and staging workflows —
including forcing a specific variant to verify a targeted experience. It attaches a Lit-based panel
to document.body, reads preview content through a Contentful Delivery API client, and talks to
your Web SDK instance through the browser preview bridge.
- Install
@contentful/optimization-web-preview-panelonly when your app needs browser authoring tooling. - Gate the dynamic import behind an environment value so production bundles can drop preview code
when the gate is replaced with
falseat build time. - Attach the panel after the SDK singleton and either a Contentful client or pre-fetched preview
entries exist.
attachOptimizationPreviewPanel(...)useswindow.contentfulOptimizationby default; passoptimizationwhen your instance is not the global one. - Pass a CSP
noncewhen strict Content Security Policy rules require one for Lit styles.
Copy this: Install the preview package.
Adapt this to your use case: an environment-gated dynamic import and attach.
If your app already loads preview content through GraphQL, SSR, a loader, or a proxy, pass
entries: { audiences, experiences } instead of contentful; when entries is provided the panel
does not fetch through contentful. While the panel drawer is open, Web Components entries live-
update so preview overrides render; manual renderers still need a states.selectedOptimizations
subscription to react to preview overrides.
Advanced integrations
Hybrid Node SSR and browser continuity
Integration category: Advanced or production-only
Use this integration when the same app uses @contentful/optimization-node on the server and
@contentful/optimization-web in the browser, and you want the same visitor’s profile to carry
across the boundary.
- Decide whether the server or browser owns the first personalization decision for each route.
- Share the anonymous profile identifier through the SDK’s
ANONYMOUS_ID_COOKIEvalue (ctfl-opt-aid) when consent permits durable profile continuity. This cookie is SDK-owned — match the exact name; do not invent your own. - Write the cookie from the server with
Path=/and a same-site policy that matches your app, and do not mark itHttpOnly— the browser SDK must read it to keep the same profile after takeover. - Use
trackCurrentPage({ initialPageEvent: 'skip', ... })for the first browser route when the server already emitted the same initial page event, so the browser does not duplicate it. - On consent denial or revocation, clear the shared cookie and avoid persisting a returned profile id. Treat server-rendered personalized HTML as personalized output for cache policy.
Follow this pattern: build the shared anonymous-id Set-Cookie on the server.
ANONYMOUS_ID_COOKIE re-exports the core constant and equals 'ctfl-opt-aid'. For the lower-level
mechanics, see
Profile synchronization between client and server.
Strict consent, storage, and delivery controls
Integration category: Advanced or production-only
Configure these only after your privacy, analytics, and platform owners agree on the event posture.
- Set
allowedEventTypes: []when no Optimization event may emit before explicit consent. (The default allowsidentifyandpage.) - Use
cookie(domain,expiresin days — default 365) when the profile-id cookie needs a specific domain or lifetime. - Use
queuePolicywhen the default retry and offline-queue behavior does not match your limits. - Use
onEventBlocked(andstates.blockedEventStream) for diagnostics when consent orallowedEventTypesblock events.
Adapt this to your use case:
Blocked events are not replayed when consent later changes. If the current route, flag, or entry state still qualifies after consent, the SDK can emit a fresh current-state event.
Production checks
Before release, verify these behaviors in the target deployment:
- Credentials and runtime configuration — the browser receives the intended Optimization client id, environment, Contentful space/environment/host, API base URLs, app metadata, and locale; no Management API token is exposed to the browser.
- Consent behavior — default-on integrations set
defaults: { consent: true }only when policy permits; CMP-driven integrations keep consent unset until a choice exists, useallowedEventTypes: []for strict opt-in, block non-allowed events before consent, and clear profile continuity on withdrawal. - Event delivery —
page(),identify(),track(), entry views/clicks/hovers, and flag views are accepted or blocked exactly as policy expects, andstates.blockedEventStreamstays empty for events that should be allowed. - Content fallback — missing selections, unresolved links, all-locale CDA responses, or a failed Experience API call render baseline content instead of breaking the page.
- Duplicate-tracking prevention — SPA routes use stable route keys via
trackCurrentPage, subscriptions register once per app root,messageIddedupe is applied before forwarding, the resolved (not baseline) entry id is used for tracking, and element tracking is not enabled twice for the same node. - Privacy and governance — profile identifiers, traits, forwarded fields,
localStorageusage, thectfl-opt-aidcookie, and retention match the app’s approved policy. - Local validation path — compare the app against the Web SDK reference implementation and run its checks locally.
Copy this:
Troubleshooting
Reference implementations to compare against
- Web SDK Vanilla JS reference implementation: Vanilla
browser initialization, Web Components entry rendering, consent state,
page(), entry resolution, merge tags, live updates, and automatic and manual entry interaction tracking. - Web SDK React Adapter reference implementation: A
local React adapter built directly on
@contentful/optimization-web, including singleton lifecycle, React Router page events, state subscriptions, Rich Text merge tags, entry resolution, and tracking metadata. - Web SDK Angular reference implementation: Angular services and standalone components that use the Web SDK directly, including route events, consent, identify/reset, nested entries, Rich Text merge tags, Custom Flags, and interaction tracking.
- Node SDK SSR + Web SDK Vanilla JS reference implementation: Hybrid server/browser continuity with shared anonymous-id cookies, consent-aware persistence, and browser-side Web SDK takeover.
Use the Web SDK package README for package orientation, and the generated Web SDK reference for exhaustive API signatures.