Use this concept document when a Node or stateless server runtime owns personalization and rendering, but the application still needs Analytics events for server-rendered content. It explains what the Node SDK can track from the server, what requires a browser runtime, and how server-generated HTML can use the Web SDK for interaction tracking without moving personalization client-side.
For step-by-step server setup, see Integrating the Optimization Node SDK in a Node app. For browser setup, see Integrating the Optimization Web SDK in a web app. For Web SDK interaction tracking mechanics, see Interaction tracking in Web SDKs. For profile handoff between server and browser, see Profile synchronization between client and server.
Choose the runtime path before designing the event flow. The SDK that renders or observes the interaction decides which facts are available.
| Path | Runtime responsibility | Use when |
|---|---|---|
@contentful/optimization-node |
Bind request consent, locale, profile, and page context; call Experience API methods; resolve entries; emit server-known events. | Server rendering owns personalization, and the event is a request fact or server-observed business action. |
@contentful/optimization-web |
Own browser consent state, profile state, storage, automatic DOM observation, browser queues, and Insights delivery. | Non-React or custom browser code needs view, click, hover, route, or manual element tracking after HTML reaches the page. |
@contentful/optimization-react-web |
Wrap the Web SDK with React browser providers, hooks, router trackers, and OptimizedEntry from @contentful/optimization-react-web. |
React browser apps need framework-owned state, route page tracking, entry wrappers, or browser-side entry personalization. |
@contentful/optimization-nextjs |
Own Next.js adapter surfaces: server helpers and ServerOptimizedEntry from @contentful/optimization-nextjs/server, request helpers from @contentful/optimization-nextjs/request-handler, tracking helpers from @contentful/optimization-nextjs/tracking-attributes, and client wrappers from @contentful/optimization-nextjs/client. |
Next.js apps need server-owned personalization, request and cookie helpers, SSR tracking attributes, and client tracking boundaries. |
| First-party browser collector plus Node SDK | Observe browser interactions in application code, post observations to an app endpoint, validate policy, and call request-bound Node SDK tracking methods. | The browser cannot run the Web SDK, but the app can own DOM observation, payload mapping, profile continuity, and retries. |
Apply these constraints before choosing server-only, hybrid, or manual tracking:
allowedEventTypes permits
the event type.ctfl-opt-aid, browser profile state, selected optimizations, and changes only when that
policy permits durable continuity.component, component_click, and
component_hover. Custom Flag views use component with componentType: 'Variable', but
allowedEventTypes also accepts flag as a Custom Flag view selector. component allows both
entry views and flag views before consent; flag narrows pre-consent admission to Custom Flag
views without entry views.defaults.profile. In React Web and Next.js provider handoff, pass
server-returned Optimization data through serverOptimizationState. In Next.js page-level
handoff, render NextjsOptimizationState under existing SDK context. The profile can also come
from browser-persisted profile state that persistence consent allows the SDK to load, or a browser
Experience API call such as page(), identify(), track(), or sticky trackView().localStorage and the ctfl-opt-aid cookie when
persistence consent permits continuity; if storage fails or is unavailable, continuity is limited
to in-memory state.The Node SDK can emit events, but it cannot observe a rendered page after the response leaves the server. That boundary is the main design constraint for interaction tracking in SSR, server functions, and other stateless environments.
A server request can know:
resolveOptimizedEntry() selected from the current selectedOptimizationsA server request cannot know by itself:
This means Node-only tracking can describe server-observed facts. Accurate view, click, and hover tracking for server-generated HTML still needs code in the browser or another runtime that can observe the final user interface.
The Node SDK extends the stateless Core runtime. You can create one singleton SDK instance per
module or process, but that singleton does not keep durable profile, consent, page, cookie, session,
or browser-storage state across requests. Bind each incoming request's inputs with forRequest()
before emitting events.
The object returned by forRequest() is request-local. It retains the profile, consent, locale,
page context, and request options passed at construction for that object's lifecycle. Experience API
responses from page(), identify(), track(), screen(), and sticky trackView() update that
request-local profile, so later calls on the same request object can use the updated profile. Do not
reuse a request object across independent HTTP requests.
In practice, the application must supply:
ANONYMOUS_ID_COOKIE (ctfl-opt-aid) or a sessionpath, url, referrer, query, and localeThe stateless methods are side-effecting API calls, not cacheable reads. Cache raw Contentful
delivery payloads and resolve them per request. Do not cache page(), identify(), track(),
screen(), trackView(), trackClick(), trackHover(), or trackFlagView() responses as if they
were pure data lookups.
Tracking uses two API paths with different semantics:
| Path | Methods | Purpose | Profile behavior |
|---|---|---|---|
| Experience API | page(), identify(), screen(), track(), sticky trackView() |
Evaluates or updates a profile and returns accepted/data event results. | Uses the profile ID bound with forRequest(). If absent, the API can create a profile. |
| Insights API | non-sticky trackView(), trackClick(), trackHover(), trackFlagView() |
Sends fire-and-forget Analytics interaction events. | Requires a request-bound profile because there is no ambient SDK state. |
Sticky entry views are the exception that touches both paths. In Node, trackView({ sticky: true })
sends a view event through Experience first, then sends the paired Insights event using the profile
returned by the Experience response. Non-sticky trackView() only uses Insights and therefore
requires a request-bound profile ID.
The interaction wire event types are:
| SDK method | Wire type | Common meaning |
|---|---|---|
trackView() |
component |
Entry exposure. |
trackClick() |
component_click |
Entry click. |
trackHover() |
component_hover |
Entry hover. |
trackFlagView() |
component with componentType: 'Variable' |
Custom Flag exposure. |
Use the Node SDK for events that the server can state truthfully.
Server-side page() is the normal SSR entry point. It records the page request, returns
OptimizationData, and gives the server profile, selectedOptimizations, and changes for the
render.
The examples below bind application-owned consent into a request-scoped client. Node SDK event calls
fail closed except for the configured allowedEventTypes; the Node default permits identify and
page before consent and labels those events as not consented.
Choose an application locale from your router, i18n, or request logic. Use that same value for CDA
fetches and pass it to forRequest({ locale }) when Experience API responses and events need to use
that locale. For the broader locale model, see
Locale handling in the Optimization SDK Suite.
const appLocale = getAppLocale(req)
const requestOptimization = optimization.forRequest({
consent: {
events: appPolicyAllowsOptimizationEvent(req),
persistence: appPolicyAllowsOptimizationEvent(req),
},
locale: appLocale,
eventContext: {
page: {
path: req.path,
query,
referrer: req.get('referer') ?? '',
search: url.search,
url: url.toString(),
},
userAgent: req.get('user-agent') ?? 'node-server',
},
profile: profileId ? { id: profileId } : undefined,
})
const pageResult = await requestOptimization.page()
const pageResponse = pageResult.accepted ? pageResult.data : undefined
if (!pageResponse) {
renderBaselineResponse()
return
}
Use server-side track() for server-known business events:
const appLocale = getAppLocale(req)
if (!pageResponse) {
return
}
const requestOptimization = optimization.forRequest({
consent: true,
locale: appLocale,
profile: pageResponse.profile,
})
await requestOptimization.track({
event: 'quote_requested',
properties: {
plan: 'enterprise',
source: 'pricing-page',
},
})
Use server-side trackView() only when the event definition is "the server rendered this entry into
the response", not "the user saw this entry in the viewport". If the business meaning requires real
visibility, use browser tracking.
import { randomUUID } from 'node:crypto'
const appLocale = getAppLocale(req)
if (!pageResponse) {
return
}
const { entry: resolvedEntry, selectedOptimization } = optimization.resolveOptimizedEntry(
baselineEntry,
pageResponse.selectedOptimizations,
)
const requestOptimization = optimization.forRequest({
consent: true,
locale: appLocale,
profile: pageResponse.profile,
})
await requestOptimization.trackView({
componentId: resolvedEntry.sys.id,
experienceId: selectedOptimization?.experienceId,
sticky: selectedOptimization?.sticky ?? false,
variantIndex: selectedOptimization?.variantIndex,
viewDurationMs: 0,
viewId: randomUUID(),
})
Use trackClick() and trackHover() on the server only for interactions that the server actually
receives, such as a POST route, redirect route, or first-party event collection endpoint. A server
cannot infer a click or hover from the original HTML response.
The Web SDK owns browser runtime mechanics that stateless Node does not own:
ctfl-opt-aidsendBeacon and fetch(..., { keepalive: true }) delivery paths for Insights eventsoptimization.tracking.enableElement(...)For entry views, the Web SDK uses IntersectionObserver, dwell-time timers, visibility-change pause
and resume, periodic duration updates, and final duration events when visibility ends. For clicks,
it listens at the document level and resolves the nearest tracked entry plus a semantic clickable
path. For hovers, it uses pointer or mouse enter and leave events with dwell-time timers.
Those browser mechanics are useful even when the server owns every personalization decision.
The hybrid pattern is:
@contentful/optimization-node on the server to evaluate the request, resolve entries, and
render personalized HTML.@contentful/optimization-web in the browser for consent, profile continuity, and
interaction tracking.resolveOptimizedEntry() in the browser unless the
application also wants browser-side personalization after hydration.This pattern keeps the personalization contract server-side while using the browser SDK for the part of tracking that can only be measured in the browser.
Use SDK helpers when available instead of copying the attribute map into application code. In
Next.js, ServerOptimizedEntry renders the Web SDK tracking attributes from the baseline entry and
the ResolvedData returned by resolveOptimizedEntry(). For custom SSR wrappers, call
getServerTrackingAttributes() from @contentful/optimization-nextjs/tracking-attributes. Non-Next
runtimes can call resolveOptimizedEntryTrackingAttributes() from
@contentful/optimization-web/tracking-attributes when they already have the same baseline entry
and resolved data shape.
import { getServerTrackingAttributes } from '@contentful/optimization-nextjs/tracking-attributes'
const trackingAttributes = getServerTrackingAttributes(baselineEntry, resolvedData, {
clickable: true,
trackHovers: false,
})
return <article {...trackingAttributes}>...</article>
Those helpers stay aligned with the current Web and React Web tracking attributes, including
per-element control attributes such as data-ctfl-track-views, data-ctfl-track-clicks,
data-ctfl-track-hovers, data-ctfl-clickable, data-ctfl-view-duration-update-interval-ms, and
data-ctfl-hover-duration-update-interval-ms.
If an application renders raw attributes manually, keep the stable browser tracking payload contract separate from SDK control metadata:
| Attribute | Payload role |
|---|---|
data-ctfl-entry-id |
Required for automatic entry tracking. The browser tracker sends this resolved entry ID as componentId. |
data-ctfl-optimization-id |
Optional optimized-entry metadata sent as experienceId. |
data-ctfl-optimization-context-id |
Optional SDK-owned context for event enrichment and diagnostics. It is not a public API event field. |
data-ctfl-duplication-scope |
Optional helper-emitted SDK optimization metadata. The Web SDK interaction payload does not use it. |
data-ctfl-sticky |
Optional true value that marks entry views as sticky. |
data-ctfl-variant-index |
Optional selected variant index. |
data-ctfl-baseline-id is SDK-recognized metadata for the baseline entry associated with the
resolved entry. It is not used as componentId, and the browser tracker does not send it in
interaction event payloads. The tracking payload uses the resolved entry ID from
data-ctfl-entry-id.
This browser code uses the Web SDK's default browser observation for views and clicks, opts out of
hover observation, and does not fetch entries or resolve variants. It assumes the Web SDK
constructor is provided by your browser bundle or approved script delivery path. Browser interaction
delivery depends on consent or allowedEventTypes and a current Web SDK profile:
<script>
const optimization = new ContentfulOptimization({
clientId: window.__OPTIMIZATION_CONFIG__.clientId,
environment: window.__OPTIMIZATION_CONFIG__.environment,
app: { name: document.title, version: '1.0.0' },
defaults: {
consent: window.__OPTIMIZATION_CONSENT__,
profile: window.__OPTIMIZATION_DATA__?.profile,
},
autoTrackEntryInteraction: { hovers: false },
})
document.querySelector('#accept-consent')?.addEventListener('click', () => {
optimization.consent(true)
})
document.querySelector('#reject-consent')?.addEventListener('click', () => {
optimization.consent(false)
})
</script>
This is not client-side personalization. The browser SDK is present only to own browser state, observe DOM interactions, and deliver Analytics events for elements the server already rendered.
Browser Insights events need a current Web SDK profile. A readable ctfl-opt-aid cookie gives the
browser the anonymous ID, but the Web SDK's Insights queue uses the current profile signal for event
delivery. Choose one of these patterns before enabling interaction tracking:
profile
returned by the server's page() or identify() call and pass it as defaults.profile. For
React Web and Next.js, pass the server OptimizationData through serverOptimizationState, or
render NextjsOptimizationState under an existing SDK context when a Next.js page owns the data.
Use this when the same server response already rendered personalized HTML from that profile.ctfl-opt-aid on the server, initialize the Web SDK in
the browser, call page() after your consent policy allows it, then enable tracking after the
page response populates browser profile state.In Next.js SSR integrations, initialPageEvent="skip" intentionally avoids the initial browser
Experience API page() request when the server already emitted that page event. If that skip leaves
the browser without serverOptimizationState or NextjsOptimizationState, and without a prior
persisted browser profile, automatic entry views, clicks, and hovers cannot deliver until a later
browser Experience API call populates profile state.
If the Web SDK must read ctfl-opt-aid, do not mark that cookie as HttpOnly. Configure path,
domain, and SameSite so the server route and browser code refer to the same profile.
When the browser SDK is used only for tracking:
states.selectedOptimizations for rerendering.resolveOptimizedEntry() in the browser.page() responses replace server-rendered content unless the architecture
intentionally supports live client-side updates.The Web SDK can still update browser profile state after page(), identify(), track(), or
sticky trackView() calls. If the application ignores those state changes, the rendered content
remains server-owned.
The Next.js SDK SSR reference implementation is
one concrete example of the same tracking-only browser pattern. In Next.js, prefer the
@contentful/optimization-nextjs adapter subpaths so app code uses the adapter's server,
request-handler, and client entries rather than wiring the lower-level Node and React Web packages
directly. The same ownership guidance applies to any React-based meta-framework that can render
React code on the server and hydrate part of that tree in the browser.
Keep personalization server-owned by enforcing these boundaries:
sdk.resolveOptimizedEntry(...), and
render plain React elements. In Next.js, those imports come from
@contentful/optimization-nextjs/server and @contentful/optimization-nextjs/request-handler.data-ctfl-* tracking attributes,
so the browser tracking runtime can observe them after hydration.OptimizationRoot, router page tracking,
consent controls, identify controls, and automatic interaction tracking. In Next.js, those imports
come from @contentful/optimization-nextjs/client.OptimizationRoot and router page trackers stay behind the framework's client-only boundary, so
the browser runtime is not instantiated during SSR. Next.js App Router can render the adapter's
Client Component exports from a Server Component layout; other frameworks need the equivalent
client-only island, lazy hydration, or browser-only wrapper.OptimizedEntry, useOptimizedEntry, or browser-side
resolveOptimizedEntry().defaults.selectedOptimizations or
states.selectedOptimizations to choose entry variants. When persistence consent is true, the Web
SDK can load persisted selected optimizations for state continuity, but tracking-only client code
must not use browser selected-optimization state to render already server-rendered entries.This split avoids the common accidental-client-personalization path in React apps. OptimizedEntry
is the React Web SDK's browser-personalization component; using it in a hydrated client tree can
cause the browser to resolve variants from client SDK state. For server-only personalization, render
the resolved entry in the server-owned render path and use the client SDK only for tracking and
controls.
After hydration, client actions can still update the profile. For example, sdk.identify() can
associate the visitor with a known user, and sdk.consent(true) can allow interaction tracking.
Those actions do not change the already rendered HTML in the reference pattern. The user sees
updated personalization on the next server request, such as a refresh or full navigation, when the
server SDK evaluates the updated profile and renders a new response.
The exact framework primitive is less important than the ownership boundary. If a framework can run the same React module on both the server and the browser, do not put personalization and tracking concerns in that shared module. Keep server SDK calls in server-only code, keep browser SDK calls in client-only code, and exchange only durable handoff data such as the profile ID and rendered tracking attributes.
A server-only application can still emit useful tracking, but the event names must match server facts.
Use Node-only tracking for:
Do not use Node-only tracking for:
If the application wants to count entry exposure as "included in SSR HTML", make that definition explicit in dashboards and experiment analysis. It is a different metric from a browser viewport view.
A consumer can choose not to run the Web SDK in the browser, but then the application owns the browser tracking system. The server Node SDK does not fill that gap by itself.
A manual solution needs to implement at least these areas:
| Area | Work the application must own |
|---|---|
| Identity | Read or receive the profile ID, handle anonymous and known identity, keep server and browser continuity aligned, and avoid cross-user leakage. |
| Consent | Store the user's decision, block or allow event types consistently, and define what happens to profile continuity after revocation. |
| Event payloads | Build valid event shapes with channel, context, messageId, timestamps, componentType, componentId, experienceId, and variantIndex. |
| DOM registry | Find server-rendered entry elements, detect added and removed elements, support nested entries, and clean up observers. |
| View tracking | Use IntersectionObserver, define visible ratio and dwell time, pause on hidden tabs, emit periodic duration updates, emit final events, and reuse viewId within a visibility cycle. |
| Click tracking | Resolve the tracked entry from the click target, distinguish real clickable paths, support semantic elements and application clickability hints, and avoid duplicate events. |
| Hover tracking | Ignore touch pointer hovers, measure dwell time, emit periodic and final hover durations, and clean up event listeners. |
| Delivery | Batch events by profile, retry or drop predictably, flush on visibilitychange or unload, and use sendBeacon or keepalive where appropriate. |
| Sticky views | Send sticky views through Experience, send paired Insights events, persist returned profile state, and avoid repeated sticky mutations for the same element. |
| Diagnostics | Record blocked events, failed deliveries, missing profile state, invalid metadata, and observer errors without breaking the host page. |
There are two common manual transport choices:
trackView(),
trackClick(), or trackHover() through the Node SDK. This keeps API details server-side but
still requires custom browser observation logic.The manual endpoint option can be appropriate for strict data-exposure policies, but it is not a small replacement for the Web SDK. It moves the transport and policy boundary to the server while leaving the hardest observation work in browser code.
Serverless and stateless environments add operational constraints to tracking:
For server-side events that matter, await the Node SDK call before the response completes or enqueue the event in a durable application-owned queue. For browser-observed interactions, prefer browser delivery through the Web SDK or an application endpoint that can accept events after the original HTML request has finished.
| Architecture | Use when | Tradeoff |
|---|---|---|
| Node SDK only | Personalization is server-side and analytics only needs request or server-known events. | No accurate viewport, click, or hover tracking for static HTML unless those interactions hit the server. |
| Node SDK plus Web SDK tracking | Personalization is server-side, but rendered entries need browser view, click, or hover tracking. | Adds a browser SDK, consent wiring, and profile handoff, but avoids custom observer and delivery code. |
| Node SDK plus manual browser collector | The browser cannot run the Web SDK, but the app can own custom observation code and send events to a first-party endpoint. | High implementation and maintenance cost. The app owns browser observers, payload semantics, retries, and diagnostics. |
| Browser SDK owns personalization and tracking | The first render can be baseline or loading state, and the browser can resolve variants after hydration. | More browser work and possible content shift, but profile state and interaction tracking live in one runtime. |
The recommended hybrid for server-generated personalized HTML is Node SDK plus Web SDK tracking. That architecture keeps the rendering decision on the server and uses the browser SDK only for the runtime signals that the server cannot observe.
Use this checklist when implementing interaction tracking for Node-rendered HTML:
profile.id when consent allows continuity.{ profile: { id } } into stateless Node calls when a profile ID exists.data-ctfl-entry-id with the resolved entry ID, not only the baseline entry ID.data-ctfl-optimization-id, data-ctfl-sticky, and data-ctfl-variant-index when an
entry is an optimized variant.