Integrate the Optimization Node SDK in a Node app
Table of contents
- Overview
- Scope and capabilities
- The integration flow
- 1. Install and initialize the SDK
- 2. Turn the Express request into SDK event context
- 3. Handle consent in your application layer
- 4. Decide how you will persist the profile ID
- 5. Call
page()andidentify()at the right time - 6. Resolve Contentful entries with
selectedOptimizations - 7. Resolve merge tags and custom flags
- 8. Emit follow-up server events when they matter
- Caching and cache safety
- Know when the Web SDK belongs in the architecture
- Reference implementations to compare against
Overview
This guide provides the required steps when you want to implement server-side personalization in a Node runtime such as Express, a custom SSR server, or a server-side function. The examples below use Express, but the same flow applies to any Node request handler.
Scope and capabilities
The Node SDK is the server-side package for Node-based applications in the Optimization SDK Suite. It lets consumers:
- Evaluate a request and receive profile data, selected optimizations, and Custom Flag change types
- Stitch anonymous and known identities together when a user becomes known
- Render optimized Contentful entries before HTML leaves the server
- Render merge tags against the current profile data
- Emit server-side optimization and analytics events
- Share an anonymous profile identifier with the Web SDK when the app has both SSR and browser code
It also does not replace your Contentful delivery client. Your app still fetches entries from Contentful. The Node SDK helps you choose the right variant for the current profile after that content has been fetched.
The integration flow
Most Node integrations follow this high-level sequence:
- Create one SDK instance for the Node process.
- Read the request-scoped inputs your app owns: consent state, existing
profile.id, known user identity, and page context. - Call the SDK to evaluate the request and, when appropriate, associate a known user with the current profile.
- Use the returned profile data, selected optimizations, and flag changes to render the response.
- Persist the returned
profile.idand emit follow-up events only when your consent policy allows it.
The two Node reference implementations in our Github repository show that pattern in working applications:
1. Install and initialize the SDK
To install and initialize the SDK, follow these steps:
- Install the package in your Node app:
pnpm add @contentful/optimization-node
- Create the SDK once and reuse it across requests:
import ContentfulOptimization from '@contentful/optimization-node'
function required(name: string): string {
const value = process.env[name]
if (!value) {
throw new Error(`Missing environment variable: ${name}`)
}
return value
}
export const optimization = new ContentfulOptimization({
clientId: required('CONTENTFUL_OPTIMIZATION_CLIENT_ID'),
environment: process.env.CONTENTFUL_OPTIMIZATION_ENVIRONMENT ?? 'main',
app: {
name: 'my-express-app',
version: '1.0.0',
},
api: {
experienceBaseUrl: process.env.CONTENTFUL_EXPERIENCE_API_BASE_URL,
insightsBaseUrl: process.env.CONTENTFUL_INSIGHTS_API_BASE_URL,
},
logLevel: 'error',
})
- Treat that SDK as a module-level singleton for the current Node process. Do not create a new
ContentfulOptimizationinstance per incoming request. Instead, compute request-scoped Experience options per request and pass them as the final argument to stateless event methods.
- The reference implementations in this repo use
PUBLIC_...environment variable names. A consumer app can use any environment variable names that fit its deployment setup. - On modern Node runtimes, the built-in
fetchimplementation is usually enough. If your runtime does not expose a standard Fetch API, providefetchOptions.fetchMethod.
2. Turn the Express request into SDK event context
The SDK can accept request-scoped event context such as locale, user agent, and page information. That context must be built fresh for every incoming request.
The reference implementations do this by translating the Express request into UniversalEventBuilderArgs:
import type { Request } from 'express'
import type {
CoreStatelessRequestOptions,
UniversalEventBuilderArgs,
} from '@contentful/optimization-node/core-sdk'
function toQueryValue(value: unknown): string | null {
if (value === undefined || value === null) return null
if (typeof value === 'string') return value
if (Array.isArray(value)) return value.map(String).join(',')
return JSON.stringify(value)
}
function getRequestContext(req: Request): UniversalEventBuilderArgs {
const url = new URL(`${req.protocol}://${req.get('host') ?? 'localhost'}${req.originalUrl}`)
const query = Object.keys(req.query).reduce<Record<string, string>>((acc, key) => {
const stringValue = toQueryValue(req.query[key])
if (stringValue !== null) {
acc[key] = stringValue
}
return acc
}, {})
return {
locale: req.acceptsLanguages()[0] ?? 'en-US',
userAgent: req.get('user-agent') ?? 'node-server',
page: {
path: req.path,
query,
referrer: req.get('referer') ?? '',
search: url.search,
url: url.toString(),
},
}
}
function getExperienceRequestOptions(req: Request): CoreStatelessRequestOptions {
return {
locale: req.acceptsLanguages()[0] ?? 'en-US',
}
}
The exact page fields do not need to come from Express. The important part is that the app passes a stable, request-specific description of the current page or route.
getRequestContext(req).locale affects the event payload context.
getExperienceRequestOptions(req).locale affects the Experience API request itself. Those two locale values are intentionally separate, even if your app derives them from the same request header.
3. Handle consent in your application layer
The Node SDK does not expose a server-side consent() state the way stateful SDKs do. In a Node app, consent belongs in your application layer.
That usually means your app needs to:
- Store the user's consent decision in its own cookie, session, or user-preference store
- Decide which high-level SDK methods are allowed before consent, after consent, and after consent revocation.
One common conservative pattern is:
- When consent is unknown or denied, do not persist
profile.idand do not emit follow-up tracking events. In many applications, that also means rendering baseline content until consent exists. - When consent is granted, switch back to normal requests and persist the returned
profile.id. - When consent is revoked, clear the stored anonymous ID and stop sending further optimization traffic until consent is granted again.
If your app stores consent in cookies, register cookie parsing middleware before reading req.cookies. The next section shows the same Express setup for profile persistence.
import type { Request, Response } from 'express'
import { ANONYMOUS_ID_COOKIE } from '@contentful/optimization-node/constants'
const OPTIMIZATION_CONSENT_COOKIE = 'ctfl-opt-consent'
function hasOptimizationConsent(req: Request): boolean {
return req.cookies[OPTIMIZATION_CONSENT_COOKIE] === 'true'
}
function clearOptimizationIdentity(res: Response): void {
res.clearCookie(ANONYMOUS_ID_COOKIE, { path: '/' })
}
4. Decide how you will persist the profile ID
Because the Node SDK is stateless, it will not remember a visitor between requests on its own. Your app needs to persist the returned profile.id somewhere and pass it back into later SDK calls when your consent policy allows it.
There are two common approaches:
- Server-only app: keep the ID in a session or first-party cookie.
- Hybrid SSR + browser app: store the ID in the shared
ANONYMOUS_ID_COOKIEso the Node SDK and Web SDK can continue the same anonymous journey.
With Express and cookies, the shared-cookie approach looks like this:
import cookieParser from 'cookie-parser'
import type { Request, Response } from 'express'
import { ANONYMOUS_ID_COOKIE } from '@contentful/optimization-node/constants'
app.use(cookieParser())
function getProfileFromRequest(req: Request): { id: string } | undefined {
const id = req.cookies[ANONYMOUS_ID_COOKIE]
return typeof id === 'string' && id.length > 0 ? { id } : undefined
}
function persistProfile(res: Response, profileId?: string): void {
if (!profileId) return
res.cookie(ANONYMOUS_ID_COOKIE, profileId, {
path: '/',
sameSite: 'lax',
})
}
This is the same cookie name used in the hybrid reference implementation.
If your app will also run @contentful/optimization-web in the browser, avoid marking that cookie as HttpOnly, because browser-side code needs to read it. If your app is server-only, a session store is equally valid. If consent is revoked, clear the same cookie or session value.
5. Call page() and identify() at the right time
The Node SDK returns optimization data from page(), identify(), screen(), track(), and sticky trackView() calls. In a typical SSR route, page() is the most important entry point.
This is a minimal Express shape:
import type { Request } from 'express'
import type { OptimizationData } from '@contentful/optimization-node/api-schemas'
function getAuthenticatedUserId(req: Request): string | undefined {
const userId = req.query.userId
return typeof userId === 'string' && userId.length > 0 ? userId : undefined
}
app.get('/', async (req, res) => {
const consented = hasOptimizationConsent(req)
if (!consented) {
return res.json({
profile: undefined,
changes: undefined,
selectedOptimizations: undefined,
})
}
const requestContext = getRequestContext(req)
const requestOptions = getExperienceRequestOptions(req)
const existingProfile = getProfileFromRequest(req)
const pageResponse: OptimizationData | undefined = await optimization.page(
{
...requestContext,
profile: existingProfile,
},
requestOptions,
)
const userId = getAuthenticatedUserId(req)
const identifyResponse = userId
? await optimization.identify(
{
...requestContext,
profile: pageResponse?.profile ?? existingProfile,
userId,
traits: { authenticated: true },
},
requestOptions,
)
: undefined
if (consented) {
persistProfile(res, identifyResponse?.profile?.id ?? pageResponse?.profile?.id)
}
res.json({
profile: identifyResponse?.profile ?? pageResponse?.profile,
changes: identifyResponse?.changes ?? pageResponse?.changes,
selectedOptimizations:
identifyResponse?.selectedOptimizations ?? pageResponse?.selectedOptimizations,
})
})
Replace getAuthenticatedUserId() with the lookup your app actually uses, such as a session, cookie, or upstream auth middleware.
This example shows the common "evaluate first, then identify when the user is known" flow. If your policy allows a different pre-consent behavior, implement that policy in your application layer before you call SDK methods.
That route lets a consumer accomplish two things:
- Anonymous personalization:
page()evaluates the current request for an anonymous or known profile. - Identity stitching:
identify()links a known user ID to the current profile before or during the same request.
The returned OptimizationData usually gives you the three values you care about most:
profile: The current profile, including the profile ID to persistchanges: Custom Flag inputsselectedOptimizations: The variant choices to use when resolving Contentful entries.
page() and identify() call order
Both patterns appear in the reference implementations because they answer slightly different questions:
- Call
identify()and thenpage()when the current page view must be attributed to the known user identity - Call
page()and thenidentify()when the request arrived anonymous but the response must stillrender with data returned from the identify step
The important rule is simpler than the ordering nuance: always render from the most relevant response object for the user state you want on that response.
6. Resolve Contentful entries with selectedOptimizations
Once you have optimization data, fetch the baseline Contentful entry the same way your application normally does, then hand it to resolveOptimizedEntry().
In the example below, replace ArticleSkeleton with the generated Contentful skeleton type your app already uses.
import type { Entry } from 'contentful'
import * as contentful from 'contentful'
const contentfulClient = contentful.createClient({
accessToken: required('CONTENTFUL_DELIVERY_TOKEN'),
environment: required('CONTENTFUL_ENVIRONMENT'),
space: required('CONTENTFUL_SPACE_ID'),
})
type ArticleEntry = Entry<ArticleSkeleton>
async function getArticle(entryId: string): Promise<ArticleEntry> {
return await contentfulClient.getEntry<ArticleSkeleton>(entryId, {
include: 10,
})
}
app.get('/article/:entryId', async (req, res) => {
const consented = hasOptimizationConsent(req)
const requestOptions = getExperienceRequestOptions(req)
const pageResponse = consented
? await optimization.page(
{
...getRequestContext(req),
profile: getProfileFromRequest(req),
},
requestOptions,
)
: undefined
const article = await getArticle(req.params.entryId)
const { entry: optimizedArticle, selectedOptimization } = optimization.resolveOptimizedEntry(
article,
pageResponse?.selectedOptimizations,
)
if (consented) {
persistProfile(res, pageResponse?.profile?.id)
}
res.render('article', {
article: optimizedArticle,
profile: pageResponse?.profile,
selectedOptimization,
})
})
This is the main server-side personalization loop:
- Ask Optimization for the current profile's selected variants.
- Fetch the baseline Contentful entry.
- Resolve the optimized entry variant before rendering.
If your optimized entries contain linked entries or merge tags, fetch with an include depth that matches your content model. The SSR reference implementation uses include: 10 for that reason.
7. Resolve merge tags and custom flags
The Node SDK also exposes helpers for profile-aware merge tags and Custom Flags.
Merge tags
If a Rich Text field contains merge-tag entries, resolve them against the current profile while rendering the field:
import { documentToHtmlString } from '@contentful/rich-text-html-renderer'
import { INLINES } from '@contentful/rich-text-types'
import { isMergeTagEntry } from '@contentful/optimization-node/api-schemas'
const html = documentToHtmlString(richTextField, {
renderNode: {
[INLINES.EMBEDDED_ENTRY]: (node) => {
if (!isMergeTagEntry(node.data.target)) return ''
return optimization.getMergeTagValue(node.data.target, pageResponse?.profile) ?? ''
},
},
})
That is the pattern used in the SSR-only reference implementation.
Custom flags
Use getFlag() when the response includes Custom Flag changes:
const showNewNavigation = optimization.getFlag('new-navigation', pageResponse?.changes) === true
In the Node SDK, getFlag() does not auto-track flag views. If a flag exposure also needs to be captured as an Insights event, call trackFlagView() explicitly:
if (pageResponse?.profile) {
await optimization.trackFlagView({
...getRequestContext(req),
componentId: 'new-navigation',
profile: pageResponse.profile,
})
}
8. Emit follow-up server events when they matter
The Node SDK can send more than page views. Common server-side cases are:
track(): a business event triggered by a server actiontrackView(): a rendered entry view when the server knows exactly which optimized entry was shownscreen(): useful when a Node runtime fronts a non-web screen-based experiencetrackClick()andtrackHover(): available, but usually better emitted from browser code once a real interaction happens
Gate these calls with the same consent policy your app applies to page() and identify().
In stateless Node usage, Insights-backed calls need a profile. trackClick(), trackHover(),
trackFlagView(), and non-sticky trackView() must use a persisted or freshly returned profile.
Sticky trackView() can omit profile, because it can reuse the paired Experience response
profile.
Example custom event:
const requestOptions = getExperienceRequestOptions(req)
await optimization.track(
{
...getRequestContext(req),
profile: pageResponse?.profile,
event: 'quote_requested',
properties: {
plan: 'enterprise',
source: 'pricing-page',
},
},
requestOptions,
)
Example rendered-entry view event:
import { randomUUID } from 'node:crypto'
const requestOptions = getExperienceRequestOptions(req)
const viewPayload = {
...getRequestContext(req),
componentId: optimizedArticle.sys.id,
experienceId: selectedOptimization?.experienceId,
variantIndex: selectedOptimization?.variantIndex,
viewDurationMs: 0,
viewId: randomUUID(),
}
if (selectedOptimization?.sticky) {
await optimization.trackView({ ...viewPayload, sticky: true }, requestOptions)
} else if (pageResponse?.profile) {
await optimization.trackView({ ...viewPayload, profile: pageResponse.profile }, requestOptions)
}
Caching and cache safety
The Node SDK sits on one side of an important cache boundary:
- Your app fetches content from Contentful
- The Node SDK evaluates the current request and returns profile and optimization data
- Your app resolves the selected variant and renders the response.
Safe patterns:
- Cache baseline Contentful entries or query results returned by
contentful.js - Treat cached Contentful
Entryobjects as immutable - Clone a cached entry before applying request-specific transforms such as merge-tag rendering
- Resolve the selected variant from the current request's
selectedOptimizations - Render merge tags against the current request's
profile
Unsafe patterns:
- Caching full HTML responses for personalized routes without varying on all personalization inputs
- Mutating a cached
Entryobject during request rendering - Caching the result of
page(),identify(),screen(),track(), ortrackView()as if those methods were pure reads - Sharing merge-tag-rendered Rich Text across users or requests
The request-scoped SDK methods are not cacheable reads. They emit Experience or Insights events and might return updated profile state for the current visitor. Call them per request when personalization is needed.
If you want to cache variant resolution itself, key that cache by both:
- The version or identity of the baseline Contentful entry
- A fingerprint of the current
selectedOptimizations
| Artifact | Shared-cache safe? | Notes |
|---|---|---|
Raw contentful.js entry or query response |
Yes | Key by entry or query, locale, include depth, environment, host, and delivery mode |
resolveOptimizedEntry(entry, selectedOptimizations) result |
Conditionally | Safe only if keyed by the baseline entry version plus a selectedOptimizations fingerprint |
| Merge-tag-rendered rich text | No | Depends on the current request profile |
| SSR HTML with personalized content | Usually no | Safe only when the cache varies on all personalization inputs |
page(), identify(), screen(), track(), and trackView() responses |
No | These methods perform side effects and must not be memoized |
Know when the Web SDK belongs in the architecture
Use the Node SDK by itself when the server is responsible for choosing the variant and rendering the response.
Add @contentful/optimization-web when the browser also needs to participate after hydration. We recommend this when you need:
- Browser-managed consent state
- Automatic entry view, click, or hover tracking in the DOM
- Cookie-based profile continuity between SSR and client-side code
- Follow-up personalization after the first server render.
For the lower-level mechanics behind cookie-based continuity, see Profile synchronization between client and server.
The Node SSR + Web SDK reference implementation shows that setup across its server and browser flows.
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(), and rendered output that consumes resolved entries. - Node SSR + Web SDK Vanilla: cookie sharing with
ANONYMOUS_ID_COOKIEfor Node and Web SDK continuity, plus browser-side follow-up tracking and entry resolution.