Integrate the Optimization Node SDK in a Node app

Overview

Use this guide to add Contentful personalization to a Node server you already have — an Express app, a custom SSR server, or a server-side function — using @contentful/optimization-node. By the end of the quick start, one route will emit a page event and report the profile the Experience API returns for that request, without changing how your server fetches or renders content.

New to personalization? Here is the whole idea in five points:

  • In Contentful you author variants of an entry and attach them to an experience — a rule that decides which visitors see which variant.
  • On each request, Contentful’s Experience API looks at who the visitor is and picks the variant for each experience. Swapping a fetched entry for its picked variant is called resolving the entry.
  • The Experience API also returns a profile: the anonymous, per-visitor identity and state used to keep personalization consistent across requests or app launches.
  • 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.

For the stateless Node SDK, your application persists the profile between requests.

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 — an accepted page event and a profile for the request (the quick start below). A page event is accepted when your consent policy allows it and the Experience API responds; the response carries the profile and the request’s variant selections, and the route reports the profile ID. This is complete and shippable on its own.
  • Milestone 2 — a resolved entry in the response (later). You fetch an entry, resolve it against the request’s selections, and render the returned variant or baseline. See Fetch and resolve Contentful entries.

This guide uses the ContentfulOptimization class from @contentful/optimization-node. You create one instance for the whole process, then bind each incoming request to its own consent, locale, and page context with forRequest(). Your app keeps ownership of its Contentful client, consent policy, sessions, cookies, identity, routing, caching, and rendering — the Node SDK holds no per-visitor state between requests.

The examples use Express, but the same request-scoped flow applies to any Node request handler. If you also want the browser to continue personalization after the server renders, see Share continuity with the Web SDK. For a browser-only app, use the Web SDK guide instead.

Quick start

Most Node + Contentful apps share one shape: a request handler builds a response and, somewhere in it, an entry becomes rendered output. This quick start assumes that shape and proves the smallest server-side result: one route reports an accepted page event and the profile the Experience API returned for that request. It creates one process-level SDK instance, binds request-scoped consent and page context with forRequest(), and calls page() from a route.

This quick start assumes your application policy permits Optimization by default and renders no end-user consent UI. Consent has two independent parts you bind per request: events (may the SDK send events and personalize this request) and persistence (may your app store the profile ID so the same visitor stays consistent). The shorthand consent: true sets both to true; the object form { events, persistence } sets them separately. If personalization must wait for a consent decision, keep this structure and add the Apply consent policy step before you ship.

Copy this:

1pnpm add @contentful/optimization-node express

Create server.mjs.

Copy this:

1import ContentfulOptimization from '@contentful/optimization-node'
2import express from 'express'
3
4const app = express()
5const APP_LOCALE = 'en-US'
6const PORT = Number(process.env.PORT ?? 3000)
7
8function required(name) {
9 const value = process.env[name]
10
11 if (!value) {
12 throw new Error(`Missing environment variable: ${name}`)
13 }
14
15 return value
16}
17
18// Reuse one SDK instance for the process; bind request data with forRequest().
19const optimization = new ContentfulOptimization({
20 clientId: required('CONTENTFUL_OPTIMIZATION_CLIENT_ID'),
21 environment: process.env.CONTENTFUL_OPTIMIZATION_ENVIRONMENT ?? 'main',
22 locale: APP_LOCALE,
23})
24
25app.get('/', async (req, res) => {
26 const host = req.get('host') ?? `localhost:${PORT}`
27 const url = new URL(`${req.protocol}://${host}${req.originalUrl}`)
28 const requestOptimization = optimization.forRequest({
29 // Default-on consent lets page() emit events and lets the app persist returned profile IDs.
30 consent: { events: true, persistence: true },
31 locale: APP_LOCALE,
32 // This request-local context is attached to the emitted page event.
33 eventContext: {
34 locale: APP_LOCALE,
35 userAgent: req.get('user-agent') ?? 'node-server',
36 page: {
37 path: req.path,
38 query: {},
39 referrer: req.get('referer') ?? '',
40 search: url.search,
41 url: url.toString(),
42 },
43 },
44 })
45
46 // page() evaluates the request and returns the profile for this response.
47 const pageResult = await requestOptimization.page()
48
49 if (!pageResult.accepted || !pageResult.data) {
50 res.status(204).end()
51 return
52 }
53
54 res.json({
55 profileId: pageResult.data.profile.id,
56 })
57})
58
59app.listen(PORT, () => {
60 console.log(`Optimization quick start listening on http://localhost:${PORT}`)
61})

Start the app with your Optimization client ID.

Copy this:

1CONTENTFUL_OPTIMIZATION_CLIENT_ID=your-client-id CONTENTFUL_OPTIMIZATION_ENVIRONMENT=main node server.mjs

In another terminal, verify the route.

Copy this:

1curl http://localhost:3000/

The JSON response contains a profileId when page() is accepted — the Experience API evaluated the request and returned a profile. An accepted event does not by itself prove a variant was chosen: until you author a variant attached to an experience (see Before you start), every request resolves to the baseline. To see a variant later, author an experience that targets all visitors so every request matches it automatically, then resolve an entry as shown in Fetch and resolve Contentful entries.

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 Node server you can add a route handler to, and its own Contentful fetching already working. Install express only if you are following the quick start verbatim; any Node request framework works. Install contentful if you want the SDK to fetch entries by ID and you do not already have a Delivery API client.
  • Contentful delivery credentials — space ID, delivery token, and environment — read from your server’s runtime configuration, never shipped to the browser.
  • 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 API (which picks variants) and the Insights API (which receives event and interaction delivery) each have a base URL that defaults correctly; you only set them for mocks or non-default hosts (see Install and initialize the Node SDK).

You do not need a setup inventory up front. Everything else — consent, entry resolution, identity, tracking, caching — is introduced by the section that needs it. The Node SDK holds no per-visitor state between requests: it does not manage cookies, sessions, consent, long-lived profiles, or rendering. Your app owns those and passes request inputs in; the SDK evaluates the request, resolves entries, and returns request-local data. When you give it your delivery client, it can fetch entries for you (managed fetching) via client.getEntry() for one entry or client.getEntries() for several.

Read the SDK config from your server’s runtime configuration. This guide’s examples read process.env values, and the reference implementations use PUBLIC_... names because they run against shared mock defaults; use whatever environment variable convention your deployment already uses and keep it consistent. Ship only the Contentful delivery token to any client, never a Management API token.

Core integration

Install and initialize the Node SDK

Integration category: Required for first integration

Create the SDK once for the Node process or module, then reuse that singleton across requests. Bind request-specific inputs later with forRequest().

From here on, the guide’s examples are TypeScript, because the SDK’s request and result types are most of the value in a server codebase. Run them with a TypeScript toolchain (for example tsx, ts-node, or a build step) — or drop the type annotations to get back to plain .mjs like the quick start. The quick start inlined the SDK inside server.mjs; the sections below factor initialization into a small shared module (call it optimization.ts) that each route handler imports, so there is still exactly one SDK instance per process.

  1. Install @contentful/optimization-node and contentful when the SDK will fetch entries by ID.
  2. Read the Optimization client ID and environment from your runtime configuration.
  3. Configure default locale and API endpoint overrides only when your app needs them.
  4. Export the singleton so route handlers can create request-bound SDK clients.

Copy this:

1pnpm add @contentful/optimization-node contentful

Adapt this to your use case: this is a new optimization.ts module you create; the route handlers in later sections import { optimization } from it. Replace the env-var reads with your app’s configuration mechanism.

1import ContentfulOptimization from '@contentful/optimization-node'
2import * as contentful from 'contentful'
3
4function required(name: string): string {
5 const value = process.env[name]
6
7 if (!value) {
8 throw new Error(`Missing environment variable: ${name}`)
9 }
10
11 return value
12}
13
14const contentfulClient = contentful.createClient({
15 accessToken: required('CONTENTFUL_DELIVERY_TOKEN'),
16 environment: required('CONTENTFUL_ENVIRONMENT'),
17 space: required('CONTENTFUL_SPACE_ID'),
18})
19
20// Create this once per process; route handlers import it and call forRequest() per request.
21export const optimization = new ContentfulOptimization({
22 clientId: required('CONTENTFUL_OPTIMIZATION_CLIENT_ID'),
23 contentful: {
24 client: contentfulClient,
25 // Include linked optimization entries and variants for SDK-managed entry fetches.
26 defaultQuery: { include: 10 },
27 },
28 environment: process.env.CONTENTFUL_OPTIMIZATION_ENVIRONMENT ?? 'main',
29 app: {
30 name: 'my-express-app',
31 version: '1.0.0',
32 },
33 api: {
34 experienceBaseUrl: process.env.CONTENTFUL_EXPERIENCE_API_BASE_URL,
35 insightsBaseUrl: process.env.CONTENTFUL_INSIGHTS_API_BASE_URL,
36 },
37 locale: 'en-US',
38 logLevel: 'error',
39})

Set api.experienceBaseUrl and api.insightsBaseUrl only for mock servers or non-default hosts; they default correctly otherwise. See the env-var note in Before you start for naming conventions.

Bind request context and locale

Integration category: Required for first integration

Build request context for every incoming request. The context gives SDK events a stable page or route description, user agent, and locale. The request-scoped locale also sets the Experience API locale query parameter.

  1. Choose the application Contentful locale in your router, i18n layer, or request policy.
  2. Pass the same locale to Contentful CDA requests and to forRequest({ locale: appLocale }) when Experience API responses and events need to use the same language.
  3. Derive page context from the current request instead of sharing it across requests.

Adapt this to your use case:

1import type { Request } from 'express'
2import type { UniversalEventBuilderArgs } from '@contentful/optimization-node/core-sdk'
3
4function toQueryValue(value: unknown): string | null {
5 if (value === undefined || value === null) return null
6 if (typeof value === 'string') return value
7 if (Array.isArray(value)) return value.map(String).join(',')
8
9 return JSON.stringify(value)
10}
11
12function getRequestContext(req: Request, appLocale: string): UniversalEventBuilderArgs {
13 const url = new URL(`${req.protocol}://${req.get('host') ?? 'localhost'}${req.originalUrl}`)
14
15 const query = Object.keys(req.query).reduce<Record<string, string>>((acc, key) => {
16 const stringValue = toQueryValue(req.query[key])
17
18 if (stringValue !== null) {
19 acc[key] = stringValue
20 }
21
22 return acc
23 }, {})
24
25 // Use the same locale for event context that forRequest({ locale }) sends to the Experience API.
26 return {
27 locale: appLocale,
28 userAgent: req.get('user-agent') ?? 'node-server',
29 page: {
30 path: req.path,
31 query,
32 referrer: req.get('referer') ?? '',
33 search: url.search,
34 url: url.toString(),
35 },
36 }
37}

Later snippets call getAppLocale(req) — that is your app’s per-request locale decision (from step 1), which can be as simple as a single const APP_LOCALE = 'en-US' if your app is single-locale. Wherever you see getAppLocale(req), substitute your own locale source.

For the full locale model, see Locale handling in the Optimization SDK Suite.

Integration category: Common but policy-dependent

Consent belongs to your application layer. The Node SDK accepts the request-scoped decision through forRequest({ consent }).

The snippets here and in the next section call getProfileFromRequest(req) and persistProfile(res, id) — the read and write halves of profile continuity. They are defined in Persist profile identity between requests; if you are building top to bottom, treat them as undefined/no-ops until you reach that section.

  1. If application policy permits Optimization by default and no end-user consent UI is rendered, bind each request with { events: true, persistence: true }.
  2. If consent depends on user choice, read that decision from your consent cookie, session, CMP, or preference store before making SDK calls.
  3. Persist returned profile IDs only when requestOptimization.canPersistProfile is true.
  4. When consent is revoked, clear the stored anonymous ID and stop sending further Optimization traffic until consent is granted again.

Copy this:

1const requestOptimization = optimization.forRequest({
2 // Default-on policy: events can be sent and profile continuity can be persisted.
3 consent: { events: true, persistence: true },
4 locale: appLocale,
5 eventContext: getRequestContext(req, appLocale),
6 profile: getProfileFromRequest(req),
7})

Adapt this to your use case:

1import type { Request } from 'express'
2
3const APP_PERSONALIZATION_CONSENT_COOKIE = 'app-personalization-consent'
4
5function appPolicyAllowsOptimizationEvent(req: Request): boolean {
6 return req.cookies?.[APP_PERSONALIZATION_CONSENT_COOKIE] === 'granted'
7}
8
9const allowed = appPolicyAllowsOptimizationEvent(req)
10const requestOptimization = optimization.forRequest({
11 // Use the same request decision for event delivery and app-owned profile persistence.
12 consent: { events: allowed, persistence: allowed },
13 locale: appLocale,
14 eventContext: getRequestContext(req, appLocale),
15 profile: getProfileFromRequest(req),
16})

By default, the Node SDK admits request-bound identify() and page() before event consent is granted, and labels those events with context.gdpr.isConsentGiven: false. Configure allowedEventTypes: [] if your policy requires strict opt-in before all stateless events.

Evaluate route requests with page()

Integration category: Required for first integration

Call page() for the server route or request that needs profile evaluation, variant selections, or Custom Flag changes. Render from the accepted event result for the current request.

An accepted page() returns three things your render logic uses:

  • profile — the anonymous per-visitor identity and state (defined in the intro).
  • selectedOptimizations — the whole set of variant selections the Experience API made for this request, one per matched experience. You pass this set into entry resolution.
  • changes — the computed Custom Flag values for this request. A Custom Flag is a named value you author against an experience in Contentful (a feature toggle, a string, a number) and read at runtime, instead of swapping a whole entry. See Read Custom Flags.
  1. Create a request-bound SDK client inside the route handler.
  2. Call page() before resolving personalized entries for that response.
  3. Use result.accepted and result.data to handle consent-blocked or unavailable data paths.
  4. Pass returned profile, selectedOptimizations, and changes to downstream render logic.

This route is the quick start’s handler with three additions: it gates consent on the app’s real decision (allowed), persists the profile only when canPersistProfile is true, and returns selectedOptimizations and changes alongside the profile.

Adapt this to your use case:

1app.get('/', async (req, res) => {
2 const appLocale = getAppLocale(req)
3 const allowed = appPolicyAllowsOptimizationEvent(req)
4 // Bind request data before calling stateless event methods.
5 const requestOptimization = optimization.forRequest({
6 consent: { events: allowed, persistence: allowed },
7 locale: appLocale,
8 eventContext: getRequestContext(req, appLocale),
9 profile: getProfileFromRequest(req),
10 })
11
12 // page() performs event delivery and returns request-local optimization data.
13 const pageResult = await requestOptimization.page()
14 const pageResponse = pageResult.accepted ? pageResult.data : undefined
15
16 // Persist only when the request-level consent object allows profile continuity.
17 if (requestOptimization.canPersistProfile) {
18 persistProfile(res, pageResponse?.profile.id)
19 }
20
21 res.json({
22 profile: pageResponse?.profile,
23 selectedOptimizations: pageResponse?.selectedOptimizations,
24 changes: pageResponse?.changes,
25 })
26})

The SDK does not expose direct event methods on the singleton. Call event methods on the object returned by forRequest().

Identify known users

Integration category: Common but policy-dependent

Call identify() when your request has a known user ID from an application-owned identity source. The Node SDK does not choose the identity key or fetch traits for you.

  1. Read the known user ID from authentication middleware, a session, a JWT, or an upstream account service.
  2. Bind the current anonymous profile ID, if one exists, with forRequest({ profile }).
  3. Call identify() when the user is known and your consent policy permits that event.
  4. Render from the response object that best matches the user state for the current response.

Adapt this to your use case:

1import type { Request } from 'express'
2
3function getAuthenticatedUserId(req: Request): string | undefined {
4 const userId = req.query.userId
5
6 return typeof userId === 'string' && userId.length > 0 ? userId : undefined
7}
8
9const pageResult = await requestOptimization.page()
10const pageResponse = pageResult.accepted ? pageResult.data : undefined
11const userId = getAuthenticatedUserId(req)
12
13// identify() links the app-owned user ID to the current anonymous profile.
14const identifyResult = userId
15 ? await requestOptimization.identify({
16 userId,
17 traits: { authenticated: true },
18 })
19 : undefined
20const identifyResponse = identifyResult?.accepted ? identifyResult.data : undefined
21
22const optimizationData = identifyResponse ?? pageResponse

Call identify() before page() when the current page view must be attributed to the known user. Call page() before identify() when the request arrived anonymous but the response can still use data returned from the identify step. In either order, render from the response that represents the state you want on the page.

Persist profile identity between requests

Integration category: Common but policy-dependent

The Node SDK is stateless, so it does not remember a visitor between requests. Persist only the profile ID that your consent policy allows, then pass it back through forRequest({ profile }).

  1. Choose an application session or first-party cookie for profile continuity.
  2. Install cookie parsing middleware or use your framework’s existing cookie/session reader.
  3. Read the stored ID at the start of each request.
  4. Persist the returned profile.id only when requestOptimization.canPersistProfile is true.
  5. Clear the stored ID when consent is denied or revoked.

Adapt this to your use case:

1import { ANONYMOUS_ID_COOKIE } from '@contentful/optimization-node/constants'
2import cookieParser from 'cookie-parser'
3import type { Request, Response } from 'express'
4
5app.use(cookieParser())
6
7function getProfileFromRequest(req: Request): { id: string } | undefined {
8 // Use the shared cookie name when browser SDK continuity is part of the app.
9 const id = req.cookies?.[ANONYMOUS_ID_COOKIE]
10
11 return typeof id === 'string' && id.length > 0 ? { id } : undefined
12}
13
14function persistProfile(res: Response, profileId?: string): void {
15 if (!profileId) return
16
17 // Call this only after requestOptimization.canPersistProfile is true.
18 res.cookie(ANONYMOUS_ID_COOKIE, profileId, {
19 path: '/',
20 sameSite: 'lax',
21 })
22}
23
24function clearOptimizationIdentity(res: Response): void {
25 res.clearCookie(ANONYMOUS_ID_COOKIE, { path: '/' })
26}

ANONYMOUS_ID_COOKIE is an SDK-defined constant that resolves to the cookie name ctfl-opt-aid; import it rather than hardcoding the string, and do not rename it — the browser Web SDK reads the same name. Use this shared cookie when the same app also runs the Web SDK in the browser, and do not mark it HttpOnly in a hybrid Node + Web SDK app because browser-side SDK code must read it. In a server-only app, a session store or a stricter cookie policy can be valid.

For the lower-level mechanics, see Profile synchronization between client and server.

Fetch and resolve Contentful entries

Integration category: Required for first integration

Your app owns the Contentful delivery client, credentials, and delivery policy. The preferred Contentful path passes an app-owned contentful.js client to the SDK, then calls the request-bound requestOptimization.fetchOptimizedEntry(entryId) helper after page() or identify().

This is Milestone 2: the quick start proved an accepted event and a profile; here you turn that into a rendered variant. The one genuinely new call is requestOptimization.fetchOptimizedEntry(entryId), which fetches the baseline entry, resolves the selected variant, and uses the latest accepted Experience response selections when you omit selectedOptimizations. For several known entry IDs at once, requestOptimization.fetchContentfulEntries() and requestOptimization.prefetchManagedEntries() batch the fetch — entries sharing a normalized query go through one getEntries() call, split into 100-ID chunks when large.

Two similarly named values appear from here on, and the one-letter difference is intentional:

  • selectedOptimizations (plural) is the request’s whole set of selections from page() — what you pass into resolution.
  • selectedOptimization (singular) is the one selection the resolver returns for a specific entry: which experience and variant index applied to it. Use it for tracking and analytics.
  1. Configure the SDK with contentful: { client, defaultQuery?, cache? } (done once in your optimization.ts module from Install and initialize the Node SDK).
  2. Call page() or identify() before resolving entries for the response.
  3. Call requestOptimization.fetchOptimizedEntry(entryId) inside the request handler.
  4. Render the returned entry. If resolution cannot find a matching optimization or variant, the resolver returns the baseline entry.

Adapt this to your use case: the SDK config below repeats the optimization.ts module from the install section so the contentful.client requirement is visible in one place — reuse your existing instance rather than creating a second one. The route handler is the new part.

1import * as contentful from 'contentful'
2
3const contentfulClient = contentful.createClient({
4 accessToken: required('CONTENTFUL_DELIVERY_TOKEN'),
5 environment: required('CONTENTFUL_ENVIRONMENT'),
6 space: required('CONTENTFUL_SPACE_ID'),
7})
8
9// This is the same singleton from optimization.ts; shown here so contentful.client is visible.
10const optimization = new ContentfulOptimization({
11 clientId: required('CONTENTFUL_OPTIMIZATION_CLIENT_ID'),
12 contentful: {
13 client: contentfulClient,
14 // Include linked optimization entries and variants before SDK resolution.
15 defaultQuery: { include: 10 },
16 },
17 environment: process.env.CONTENTFUL_OPTIMIZATION_ENVIRONMENT ?? 'main',
18 locale: 'en-US',
19})
20
21app.get('/article/:entryId', async (req, res) => {
22 const appLocale = getAppLocale(req)
23 const requestOptimization = optimization.forRequest({
24 consent: { events: true, persistence: true },
25 locale: appLocale,
26 eventContext: getRequestContext(req, appLocale),
27 profile: getProfileFromRequest(req),
28 })
29 // Evaluate the request before resolving entry variants for this response.
30 const pageResult = await requestOptimization.page()
31 const pageResponse = pageResult.accepted ? pageResult.data : undefined
32 const {
33 baselineEntry: article,
34 entry: optimizedArticle,
35 selectedOptimization,
36 } = await requestOptimization.fetchOptimizedEntry(req.params.entryId)
37
38 if (requestOptimization.canPersistProfile) {
39 persistProfile(res, pageResponse?.profile.id)
40 }
41
42 res.render('article', {
43 article: optimizedArticle,
44 profile: pageResponse?.profile,
45 selectedOptimization,
46 })
47})

Use requestOptimization.fetchOptimizedEntry() in request handlers. If you call optimization.fetchOptimizedEntry() on the singleton for personalized content, pass selectedOptimizations explicitly.

Use manual baseline-entry fetching plus resolveOptimizedEntry() when the app needs custom delivery behavior, GraphQL, REST without contentful.js, or an already-fetched baseline entry:

Adapt this to your use case:

1const baselineEntry = await contentfulClient.getEntry(req.params.entryId, {
2 include: 10,
3 locale: appLocale,
4})
5const { entry: optimizedArticle } = optimization.resolveOptimizedEntry(
6 baselineEntry,
7 pageResponse?.selectedOptimizations,
8)

Do not configure SDK-managed fetches or manual fetches with contentful.js withAllLocales or raw CDA locale=* responses. The resolver expects direct single-locale field values such as fields.nt_experiences and fields.nt_variants. For the entry contract, see Entry personalization and variant resolution.

Optional integrations

Resolve merge tags

Integration category: Optional

Use this helper when your Contentful content contains MergeTag entries. A MergeTag is an authored placeholder embedded in Rich Text (for example a visitor’s first name or city) that the SDK fills in from the request’s profile at render time, falling back to a default value when the profile has no value for it.

  1. Detect MergeTag entries in your Rich Text renderer.
  2. Resolve each MergeTag entry against the current request’s profile.
  3. Render the fallback value when no profile value exists.
  4. Keep merge-tag-rendered output request-local because it depends on profile data.

Adapt this to your use case:

1import { isMergeTagEntry } from '@contentful/optimization-node/api-schemas'
2import { documentToHtmlString } from '@contentful/rich-text-html-renderer'
3import { INLINES } from '@contentful/rich-text-types'
4
5const html = documentToHtmlString(richTextField, {
6 renderNode: {
7 [INLINES.EMBEDDED_ENTRY]: (node) => {
8 if (!isMergeTagEntry(node.data.target)) return ''
9
10 // MergeTag values depend on the request profile and fall back through the entry config.
11 return optimization.getMergeTagValue(node.data.target, pageResponse?.profile) ?? ''
12 },
13 },
14})

If MergeTags reference localized profile fields such as location.city or location.country, pass the same application locale to Contentful fetches and forRequest({ locale }) so profile values and entry language line up. For SDK-managed entry fetching on a request-bound client, forRequest({ locale }) supplies the managed Contentful query locale when neither contentful.defaultQuery nor the per-call query sets locale.

Read Custom Flags

Integration category: Optional

Use this helper when your Experience response includes Custom Flag changes.

  1. Read flags from the accepted Experience response’s changes.
  2. Render the server response from the returned flag value.
  3. Emit a flag-view event explicitly when your reporting policy needs a flag exposure.

The flag name you pass to getFlag() and componentId ('new-navigation' below) must match the Custom Flag key you authored in Contentful — it is not a free-choice label. getFlag() returns the authored value, so type its use to your flag (here, comparing against true for a boolean flag).

Copy this:

1// 'new-navigation' must match the Custom Flag key authored in Contentful.
2// Pass request-local changes; stateless getFlag() does not emit flag-view tracking.
3const showNewNavigation = optimization.getFlag('new-navigation', pageResponse?.changes) === true

Adapt this to your use case:

1if (appPolicyAllowsOptimizationEvent(req) && pageResponse?.profile) {
2 const requestOptimization = optimization.forRequest({
3 consent: true,
4 locale: appLocale,
5 eventContext: getRequestContext(req, appLocale),
6 profile: pageResponse.profile,
7 })
8
9 // getFlag() is read-only in Node; emit a profile-bound flag view when reporting needs it.
10 await requestOptimization.trackFlagView({
11 componentId: 'new-navigation',
12 })
13}

In the stateless Node SDK, getFlag() does not auto-track flag views.

Track server-side interactions and business events

Integration category: Optional

Use request-bound event methods when the server owns an exposure, action, or business event. Browser clicks and hovers are usually better emitted from browser SDK code because the browser observes the real interaction.

  1. Bind consent, locale, event context, and profile for the request or server action.
  2. Use track() for custom business events.
  3. Use trackView() when the server knows exactly which optimized entry was rendered.
  4. Bind a profile for Insights-only calls such as non-sticky trackView(), trackClick(), trackHover(), and trackFlagView().

Unlike a Custom Flag key, the track() event name and its properties are your own free-choice labels — pick names that fit your analytics plan; they do not need to match anything authored in Contentful.

Adapt this to your use case:

1if (appPolicyAllowsOptimizationEvent(req) && pageResponse?.profile) {
2 // Bind the current profile before emitting server-owned events.
3 const requestOptimization = optimization.forRequest({
4 consent: true,
5 locale: appLocale,
6 eventContext: getRequestContext(req, appLocale),
7 profile: pageResponse.profile,
8 })
9
10 // 'quote_requested' and its properties are your own analytics labels, not authored IDs.
11 await requestOptimization.track({
12 event: 'quote_requested',
13 properties: {
14 plan: 'enterprise',
15 source: 'pricing-page',
16 },
17 })
18}

Adapt this to your use case:

1import { randomUUID } from 'node:crypto'
2
3if (appPolicyAllowsOptimizationEvent(req) && pageResponse?.profile) {
4 // Non-sticky trackView requires a request-bound profile in stateless runtimes.
5 const requestOptimization = optimization.forRequest({
6 consent: true,
7 locale: appLocale,
8 eventContext: getRequestContext(req, appLocale),
9 profile: pageResponse.profile,
10 })
11
12 await requestOptimization.trackView({
13 componentId: optimizedArticle.sys.id,
14 experienceId: selectedOptimization?.experienceId,
15 // sticky: true also emits an Experience view before Insights delivery.
16 ...(selectedOptimization?.sticky ? { sticky: true } : {}),
17 variantIndex: selectedOptimization?.variantIndex,
18 viewDurationMs: 0,
19 viewId: randomUUID(),
20 })
21}

Sticky trackView() sends an Experience event first and can reuse the returned profile for the paired Insights event. Non-sticky trackView() and the Insights-only methods require a request-bound profile ID because the Node SDK has no ambient profile state.

For the lower-level mechanics, see Interaction tracking in Node and stateless environments.

Forward optimization context to analytics

Integration category: Optional

Use this integration when your Node app already sends server-side events to an analytics, customer-data, or tag-management destination. The Optimization SDK still sends events to Contentful; your application decides which approved Contentful context, if any, can also be forwarded.

  1. Use request-local data from the SDK call that belongs to the server request or action.
  2. Add selected Optimization fields to the same analytics event that already owns the business action.
  3. Gate both Contentful and third-party delivery with the same application consent policy.
  4. Prevent duplicate forwarding when the browser also sends a later client-side event for the same interaction.

Adapt this to your use case:

1// Use selectedOptimization from the same resolution call that produced the rendered entry.
2const { entry: resolvedHeroEntry, selectedOptimization } = optimization.resolveOptimizedEntry(
3 baselineHeroEntry,
4 pageResponse?.selectedOptimizations,
5)
6const selectedReplacementEntryId = selectedOptimization?.variants[baselineHeroEntry.sys.id]
7const selectedVariantEntryId =
8 selectedReplacementEntryId && selectedReplacementEntryId !== baselineHeroEntry.sys.id
9 ? selectedReplacementEntryId
10 : undefined
11
12analytics.track('Quote Requested', {
13 plan: 'enterprise',
14 contentful_profile_id: canForwardOptimizationProfileId ? pageResponse?.profile.id : undefined,
15 contentful_experience_id: selectedOptimization?.experienceId,
16 contentful_variant_index: selectedOptimization?.variantIndex,
17 contentful_baseline_entry_id: baselineHeroEntry.sys.id,
18 contentful_rendered_entry_id: resolvedHeroEntry.sys.id,
19 contentful_selected_variant_entry_id: selectedVariantEntryId,
20})

Use Forwarding Optimization SDK context to analytics and tag-management tools for request-local mapping, vendor examples, consent, identity, deduplication, and governance guidance.

Share continuity with the Web SDK

Integration category: Optional

Add @contentful/optimization-web when the browser also needs to participate after server render. Use the Node SDK alone when the server chooses the variant and renders the full response.

  1. Store the shared anonymous profile ID in ANONYMOUS_ID_COOKIE when consent permits persistence.
  2. Leave the shared cookie readable by browser-side code in hybrid Node + Web SDK apps.
  3. Initialize the Web SDK with the same Optimization client, environment, and application locale.
  4. Let browser code handle later client-side consent, page events, entry interactions, and live updates.

The Node SDK does not provide browser live updates or a preview UI. Keep those concerns in browser-side SDK code or app-owned Contentful preview tooling.

The Node SSR + Web SDK reference implementation shows cookie sharing with ANONYMOUS_ID_COOKIE plus browser-side follow-up tracking and entry resolution.

Advanced integrations

Integration category: Advanced or production-only

Use this section when your policy or deployment needs stricter consent behavior, diagnostics, or per-request API options.

  1. Configure allowedEventTypes: [] to block all events before event consent is granted.
  2. Configure a narrower or wider allowlist only after your privacy policy approves those event types.
  3. Use onEventBlocked for diagnostics when consent blocks a request-bound event call.
  4. Use request-scoped experienceOptions and insightsOptions for advanced API behavior such as preflight, IP override, or a custom Insights beacon sender.

Follow this pattern:

1const optimization = new ContentfulOptimization({
2 // Empty allowlist blocks page() and identify() until request consent is true.
3 allowedEventTypes: [],
4 clientId: 'your-client-id',
5 environment: 'main',
6 // Use this callback for rollout diagnostics, not user-facing error handling.
7 onEventBlocked: (event) => {
8 console.warn('Contentful Optimization event blocked', event.method, event.reason)
9 },
10})
11
12const requestOptimization = optimization.forRequest({
13 consent: { events: false, persistence: false },
14 eventContext: getRequestContext(req, appLocale),
15 // Advanced Experience API options stay request-scoped in stateless runtimes.
16 experienceOptions: { preflight: true },
17 locale: appLocale,
18 profile: getProfileFromRequest(req),
19})
20
21const pageResult = await requestOptimization.page()

If both locale and experienceOptions.locale are supplied to forRequest(), the request-scoped top-level locale wins.

Keep caches safe for personalized rendering

Integration category: Advanced or production-only

The Node SDK sits on one side of an important cache boundary: your app fetches Contentful content, the SDK evaluates the current request, and your app resolves and renders the selected variant. Cache raw Contentful delivery payloads broadly; keep profile-evaluated output request-local unless your cache varies on every personalization input.

  1. Cache baseline Contentful entries or query results by entry, query, locale, include depth, environment, host, and delivery mode.
  2. Treat cached Contentful entries as immutable, or clone them before request-specific transforms such as merge-tag rendering.
  3. Resolve variants from the current request’s selectedOptimizations, or use requestOptimization.fetchOptimizedEntry() so the request-bound helper supplies them.
  4. Render MergeTags against the current request’s profile.
  5. Do not memoize page(), identify(), track(), or trackView() results as if they were pure reads.

Use this cache-safety table when planning production caching:

ArtifactShared-cache safe?Notes
Raw contentful.js entry or query responseYesKey by entry or query, locale, include depth, environment, host, and delivery mode
SDK-managed entry cacheYesCaches baseline entries from managed getEntry() and getEntries() fetches; configure with contentful.cache or disable with cache: false
resolveOptimizedEntry(entry, selectedOptimizations) resultConditionalSafe only if keyed by the baseline entry version plus a selectedOptimizations fingerprint
Merge-tag-rendered rich textNoDepends on the current request profile
SSR HTML with personalized contentUsually noSafe only when the cache varies on all personalization inputs
page(), identify(), track(), and trackView() responsesNoThese methods perform side effects and must not be memoized

Production checks

Before releasing a Node SDK integration, verify these points:

  • Credentials and runtime configuration: the deployed runtime has the Optimization client ID, environment, API endpoint overrides if needed, Contentful delivery credentials, and the same Node runtime support you validated locally.
  • Consent behavior: default-on requests bind { events: true, persistence: true } only when policy permits it, user-choice flows bind the actual request decision, and revoked consent clears stored profile IDs.
  • Event delivery: page() and identify() return accepted results in allowed paths, blocked paths fail closed, and onEventBlocked or server logs expose consent-blocked diagnostics during rollout.
  • Content fallback behavior: baseline entries render when selectedOptimizations is missing, entries are not optimized, linked optimization entries are unresolved, or a selected variant cannot be found.
  • Duplicate tracking prevention: server-rendered exposures, browser follow-up tracking, and third-party forwarding have one owner per event in your tracking plan.
  • Privacy and governance constraints: profile IDs, full profile objects, selected optimizations, changes, and analytics payloads are forwarded only to approved destinations.
  • Local validation path: run the server against mock or test credentials, load a route that calls page(), verify a returned profile.id, render an optimized entry, and verify the baseline fallback path by testing without selectedOptimizations.

Troubleshooting

SymptomLikely causeCheck
Entry always resolves to the baselineNo variant applies, no selectedOptimizations passed, the entry is not optimized, or an all-locale payloadAuthor a variant that targets you, confirm an accepted page()/identify() ran first, fetch one locale with enough include
The variant never appears even though it is authoredThe request does not match the experience’s audience, or resolution ran without the request’s selectionsTarget all visitors for a first test or force the variant with the preview panel; pass selectedOptimizations or use the bound helper
page() returns { accepted: false }Event consent is not granted and the event type is not allow-listedBind consent.events: true for the request, or confirm allowedEventTypes permits page; inspect onEventBlocked
trackView() requires a request-bound profile id error thrownA non-sticky trackView(), trackClick(), trackHover(), or trackFlagView() ran without a bound profilePass forRequest({ profile }), or use sticky trackView() which derives the profile from its Experience response
Profile does not stay consistent across requestsThe returned profile.id is not persisted, or canPersistProfile was falsePersist the ID only when requestOptimization.canPersistProfile is true, then read it back into forRequest({ profile })
Hybrid browser sessions start with a different anonymous profileServer and browser do not share the same readable anonymous-id cookieVerify the ctfl-opt-aid cookie path and same-site settings, and that it is not HttpOnly so browser SDK code can read it
Merge tags render the fallback for every visitorNo matching profile value, or the profile locale and entry locale differConfirm getMergeTagValue() receives the request profile, and pass one APP_LOCALE to both the CDA fetch and forRequest({ locale })
Personalized output leaks between visitorsA page()/identify() response or merge-tag-rendered entry was shared through a cacheCache only raw Contentful payloads; clone before request transforms; never memoize event-method responses

Reference implementations to compare against

Use these reference implementations when you want working repository examples instead of guide snippets:

  • Node SSR Only: server-only SSR flow with page(), identify(), resolveOptimizedEntry(), getMergeTagValue(), raw Contentful entry caching, and single-locale CDA requests.
  • Node SSR + Web SDK Vanilla: consent-aware cookie sharing with ANONYMOUS_ID_COOKIE for Node and Web SDK continuity, plus browser-side follow-up tracking and entry resolution.