Use this guide when you want to add personalization to a Next.js App Router application where the server is the single source of truth for which variant to show. The Node SDK resolves entries server-side before HTML leaves the server. The React Web SDK hydrates on the client for analytics tracking and interactive controls such as consent and identify.
If you need instant client-side reactivity after identify or consent — for example, showing a personalized welcome message without a page refresh — use the Hybrid SSR + CSR takeover guide instead.
The SSR-primary pattern uses two packages together:
@contentful/optimization-node — stateless, server-side. Runs in Server Components, middleware,
and Edge Runtime. Resolves which entry variant to render before the HTML response leaves the
server.@contentful/optimization-react-web — stateful, browser-side. Initializes after hydration and
handles page view tracking, entry interaction tracking, consent, and identify. It never resolves
entry variants in this pattern.What this setup gives you:
What it does not give you:
| Concern | Where it runs | SDK used |
|---|---|---|
| Anonymous ID cookie lifecycle | Middleware (Edge Runtime) | Node SDK |
| Profile resolution and variant pick | Server Component | Node SDK (requestOptimization.page()) |
| Entry variant resolution | Server Component | Node SDK (sdk.resolveOptimizedEntry()) |
| HTML rendering | Server Component | None (plain React) |
| Page view tracking | Client (after hydration) | React Web SDK (NextAppAutoPageTracker) |
| Entry interaction tracking | Client (after hydration) | React Web SDK (trackEntryInteraction) |
| Consent management | Client (after hydration) | React Web SDK (sdk.consent()) |
| User identification | Client (after hydration) | React Web SDK (sdk.identify()) |
In practice, the integration follows this sequence:
sdk.forRequest(); the examples below use
accepted consent for a default-on policy.next/dynamic and ssr: false so it only runs in the browser.'use client' wrapper in your layout.The SSR-primary reference implementation in this repository shows that pattern in a working application:
pnpm add @contentful/optimization-node @contentful/optimization-react-web
Create the SDK once at module level. It is stateless and safe to share across all requests.
// lib/optimization-server.ts
import ContentfulOptimization from '@contentful/optimization-node'
const sdk = new ContentfulOptimization({
clientId: process.env.CONTENTFUL_OPTIMIZATION_CLIENT_ID ?? '',
environment: process.env.CONTENTFUL_OPTIMIZATION_ENVIRONMENT ?? 'main',
api: {
experienceBaseUrl: process.env.CONTENTFUL_EXPERIENCE_API_BASE_URL,
insightsBaseUrl: process.env.CONTENTFUL_INSIGHTS_API_BASE_URL,
},
contentfulLocales: {
default: 'en-US',
supported: ['en-US', 'de-DE', 'fr-FR'],
},
app: {
name: 'my-next-app',
version: '1.0.0',
},
logLevel: 'error',
})
export { sdk }
Do not create a new instance per request. The Node SDK is designed to be a process-level singleton.
Use forRequest() to bind request-scoped consent, locale, user agent, profile, page URL, and
Experience API request options before calling event methods.
The per-call { locale } request option is sent as the Experience API locale query parameter.
Call sdk.resolveRequestLocale(reqOrAcceptLanguage) for each request, use eventLocale in event
context, and use contentfulLocale for the CDA fetch and the Experience API request option when it
is present. Merge tags that reference localized profile fields such as location.city and
location.country then resolve in a language consistent with the rendered content.
Next.js middleware runs on the Edge Runtime before every request reaches a Server Component. Use it to ensure the anonymous ID cookie exists and is populated before the Server Component tries to read it.
import { sdk } from '@/lib/optimization-server'
import { ANONYMOUS_ID_COOKIE } from '@contentful/optimization-node/constants'
import { type NextRequest, NextResponse } from 'next/server'
export async function middleware(request: NextRequest): Promise<NextResponse> {
const response = NextResponse.next()
const anonymousId = request.cookies.get(ANONYMOUS_ID_COOKIE)?.value
const profile = anonymousId ? { id: anonymousId } : undefined
const { contentfulLocale, eventLocale } = sdk.resolveRequestLocale(request)
const url = new URL(request.url)
const requestOptimization = sdk.forRequest({
consent: { events: true, persistence: true },
eventContext: {
locale: eventLocale,
userAgent: request.headers.get('user-agent') ?? 'next-js-server',
page: {
path: url.pathname,
query: Object.fromEntries(url.searchParams),
referrer: request.headers.get('referer') ?? '',
search: url.search,
url: request.url,
},
},
experienceOptions: contentfulLocale ? { locale: contentfulLocale } : undefined,
profile,
})
const data = await requestOptimization.page()
if (requestOptimization.canPersistProfile && data?.profile.id) {
response.cookies.set(ANONYMOUS_ID_COOKIE, data.profile.id, {
path: '/',
sameSite: 'lax',
})
}
return response
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|api).*)'],
}
The ANONYMOUS_ID_COOKIE constant is the shared cookie name used by both the Node SDK and the React
Web SDK. Using the same name means that after hydration, the Web SDK reads the same anonymous ID
from document.cookie and continues the same profile journey the server started.
Do not mark this cookie as HttpOnly. The Web SDK reads it from the browser. The example uses a
default-on policy, so middleware binds accepted event and persistence consent before calling
requestOptimization.page(). If application policy depends on user choice, read that state before
the SDK call and clear the shared anonymous ID cookie when profile continuity is not permitted.
Inside a Server Component, read the anonymous ID cookie and bind accepted consent for the default-on request policy:
import { sdk } from '@/lib/optimization-server'
import { ANONYMOUS_ID_COOKIE } from '@contentful/optimization-node/constants'
import { cookies, headers } from 'next/headers'
export default async function Home() {
const cookieStore = await cookies()
const headerStore = await headers()
const { contentfulLocale, eventLocale } = sdk.resolveRequestLocale(
headerStore.get('accept-language'),
)
const contentfulOptions = contentfulLocale ? { locale: contentfulLocale } : undefined
const anonymousId = cookieStore.get(ANONYMOUS_ID_COOKIE)?.value
const profile = anonymousId ? { id: anonymousId } : undefined
const [baselineEntries, optimizationData] = await Promise.all([
fetchEntriesFromContentful(contentfulOptions),
sdk
.forRequest({
consent: { events: true, persistence: true },
eventContext: {
locale: eventLocale,
userAgent: headerStore.get('user-agent') ?? 'next-js-server',
},
experienceOptions: contentfulLocale ? { locale: contentfulLocale } : undefined,
profile,
})
.page(),
])
const resolvedEntries = baselineEntries.map((entry) => {
const { entry: resolved } = optimizationData
? sdk.resolveOptimizedEntry(entry, optimizationData.selectedOptimizations)
: { entry }
return resolved
})
return (
<main>
{resolvedEntries.map((entry) => (
<EntryCard key={entry.sys.id} entry={entry} />
))}
</main>
)
}
Fetch Contentful entries with include: 10 so that linked optimization data (such as
nt_experiences) is included in the response. The Node SDK needs those nested fields to evaluate
variants.
Also fetch entries with one CDA locale. Configure contentfulLocales.default for single-locale
apps, and add contentfulLocales.supported for localized apps that need request locale matching.
Use the contentfulLocale returned by resolveRequestLocale() for CDA requests when it is present.
All-locale responses from contentful.js withAllLocales or raw CDA locale=* contain
locale-keyed maps, while the resolver expects fields.nt_experiences and fields.nt_variants to be
direct single-locale field values. See
Entry personalization and variant resolution
for the entry contract and
Locale handling in the Optimization SDK Suite
for the broader locale model.
resolveOptimizedEntry is synchronous. It picks the correct variant from the resolved entry based
on selectedOptimizations returned by requestOptimization.page(). If no optimization applies, it
returns the baseline entry unchanged.
After hydration, the React Web SDK's trackEntryInteraction option uses a MutationObserver to
find elements with specific data-ctfl-* attributes and register interaction trackers (views,
clicks, hovers) against them. For automatic tracking to work on server-rendered entries, add
data-ctfl-entry-id to the wrapper element:
function ServerRenderedEntry({
baselineEntry,
resolvedEntry,
}: {
baselineEntry: ContentEntry
resolvedEntry: ContentEntry
}) {
return (
<div data-ctfl-entry-id={resolvedEntry.sys.id} data-ctfl-baseline-id={baselineEntry.sys.id}>
<p>{resolvedEntry.fields.title}</p>
</div>
)
}
data-ctfl-entry-id is the resolved (possibly variant) entry ID and is required for automatic
interaction tracking. data-ctfl-baseline-id is application-owned metadata for keeping the original
baseline entry ID available to your rendering code; the Web SDK does not include it in event
payloads. When the server has selected optimization context available, also render
data-ctfl-optimization-id, data-ctfl-sticky, and data-ctfl-variant-index so client-side
interaction events can carry the same optimization identifiers.
The React Web SDK depends on browser APIs (localStorage, document.cookie,
IntersectionObserver). These APIs are not available during server rendering in Next.js. Importing
the package in a Server Component causes a runtime error.
Use next/dynamic with ssr: false to prevent the SDK from loading during server rendering:
// components/ClientProviderWrapper.tsx
'use client'
import dynamic from 'next/dynamic'
import { Suspense, type ReactNode } from 'react'
const OptimizationRoot = dynamic(
() =>
import('@contentful/optimization-react-web').then((mod) => ({
default: mod.OptimizationRoot,
})),
{ ssr: false },
)
The 'use client' directive is required. next/dynamic is a Client Component feature, and the
import of @contentful/optimization-react-web must not reach Server Component module graph
resolution.
ssr: false is requiredNext.js tries to pre-render Client Components on the server (a technique sometimes called
server-side rendering of client components). Without ssr: false, Next.js attempts to render
OptimizationRoot on the server and fails because the package accesses browser globals at import
time.
ssr: false tells Next.js to skip server rendering for OptimizationRoot entirely. The SDK
initializes only after JavaScript loads in the browser. Provider-owned initialization still happens
outside React render and gates children until readiness; in normal browser rendering it uses
layout-effect scheduling so ready children can mount before first visible client paint.
Mount NextAppAutoPageTracker inside OptimizationRoot to emit page() events automatically
whenever the App Router pathname changes:
const NextAppAutoPageTracker = dynamic(
() =>
import('@contentful/optimization-react-web/router/next-app').then((mod) => ({
default: mod.NextAppAutoPageTracker,
})),
{ ssr: false },
)
Wrap the tracker in <Suspense> to prevent it from blocking the initial render. If local
diagnostics need SDK state subscriptions that are attached as soon as SDK state exists and before
the first automatically emitted page() event, use OptimizationRoot's onStatesReady prop to
subscribe to states.eventStream or states.blockedEventStream.
Assemble the client wrapper component and use it in app/layout.tsx:
// components/ClientProviderWrapper.tsx
'use client'
import dynamic from 'next/dynamic'
import { Suspense, type ReactNode } from 'react'
const OptimizationRoot = dynamic(
() =>
import('@contentful/optimization-react-web').then((mod) => ({
default: mod.OptimizationRoot,
})),
{ ssr: false },
)
const NextAppAutoPageTracker = dynamic(
() =>
import('@contentful/optimization-react-web/router/next-app').then((mod) => ({
default: mod.NextAppAutoPageTracker,
})),
{ ssr: false },
)
export function ClientProviderWrapper({
children,
contentfulLocale,
}: {
children: ReactNode
contentfulLocale?: string
}) {
return (
<OptimizationRoot
clientId={process.env.NEXT_PUBLIC_OPTIMIZATION_CLIENT_ID ?? ''}
environment={process.env.NEXT_PUBLIC_OPTIMIZATION_ENVIRONMENT ?? 'main'}
defaults={{ consent: true }}
contentfulLocales={{
default: 'en-US',
supported: ['en-US', 'de-DE', 'fr-FR'],
}}
locale={contentfulLocale}
trackEntryInteraction={{ views: true, clicks: true, hovers: true }}
logLevel="error"
>
<Suspense>
<NextAppAutoPageTracker />
</Suspense>
{children}
</OptimizationRoot>
)
}
// app/layout.tsx
import { ClientProviderWrapper } from '@/components/ClientProviderWrapper'
import { sdk } from '@/lib/optimization-server'
import { headers } from 'next/headers'
function getHtmlLang(locale: string | undefined): string {
return locale?.split('-')[0] ?? 'en'
}
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const headerStore = await headers()
const { contentfulLocale } = sdk.resolveRequestLocale(headerStore.get('accept-language'))
const htmlLang = getHtmlLang(contentfulLocale)
return (
<html lang={htmlLang}>
<body>
<ClientProviderWrapper contentfulLocale={contentfulLocale}>
{children}
</ClientProviderWrapper>
</body>
</html>
)
}
layout.tsx itself is a Server Component. ClientProviderWrapper is a Client Component, which is
correct — React's composition model allows a Server Component to render a Client Component as a
child.
Environment variables exposed to client code in Next.js must use the NEXT_PUBLIC_ prefix. The Node
SDK on the server reads variables without that prefix. Keep them separate in your .env file:
# Used by the Node SDK (server only)
CONTENTFUL_OPTIMIZATION_CLIENT_ID="your-client-id"
CONTENTFUL_OPTIMIZATION_ENVIRONMENT="main"
# Used by the React Web SDK (exposed to the browser)
NEXT_PUBLIC_OPTIMIZATION_CLIENT_ID="your-client-id"
NEXT_PUBLIC_OPTIMIZATION_ENVIRONMENT="main"
With a default-on policy, no consent UI is required. The server examples above bind
consent: { events: true, persistence: true }, and the browser provider seeds
defaults={{ consent: true }} so client-side page and interaction tracking can emit immediately.
If application policy depends on user choice, keep server request consent, the anonymous ID cookie, and browser SDK consent aligned from a Client Component that subscribes to SDK state and exposes the controls:
// components/InteractiveControls.tsx
'use client'
import { useOptimizationContext } from '@contentful/optimization-react-web'
import { useEffect, useState } from 'react'
const APP_PERSONALIZATION_CONSENT_COOKIE = 'app-personalization-consent'
function setAppConsentCookie(consented: boolean): void {
const value = consented ? 'granted' : 'denied'
document.cookie = `${APP_PERSONALIZATION_CONSENT_COOKIE}=${value}; Path=/; SameSite=Lax`
}
export function InteractiveControls() {
const { sdk, isReady } = useOptimizationContext()
const [consent, setConsent] = useState<boolean | undefined>(undefined)
useEffect(() => {
if (!sdk || !isReady) return
const sub = sdk.states.consent.subscribe((value) => {
setConsent(value)
if (typeof value === 'boolean') setAppConsentCookie(value)
})
return () => sub.unsubscribe()
}, [sdk, isReady])
if (!sdk || !isReady) return null
return (
<div>
<button
onClick={() => {
sdk.consent(consent !== true)
}}
>
{consent === true ? 'Reject consent' : 'Accept consent'}
</button>
<button onClick={() => sdk.identify({ userId: 'user-123' })}>Identify</button>
<button onClick={() => sdk.reset()}>Reset profile</button>
</div>
)
}
InteractiveControls can be mounted inside a Server Component page — React allows Client Components
to be children of Server Components.
The cookie write keeps the next server request aligned with the browser-side sdk.consent() state.
In production, replace this with the same CMP or account-preference state that drives your browser
consent UI.
Client actions update the Optimization profile via the Experience API, but they do not re-render the server-resolved content on the current page. The updated profile is reflected on the next server request (navigation or browser refresh).
Use this optional step when your Next.js app already sends events to a tag manager, customer-data platform, or analytics destination. The Optimization SDK still sends events to Contentful. Your application decides which approved Contentful context, if any, should also be forwarded.
| Reporting need | SSR-primary handoff |
|---|---|
| Server-rendered first-paint attribution | Use request-local OptimizationData and selectedOptimization in the Server Component. |
| Hydrated page and entry interaction events | Register one React Web states.eventStream subscription from onStatesReady. |
| Business event attribution | Add Contentful fields in the server action or Client Component handler that owns it. |
| Consent or duplicate-delivery verification | Use states.blockedEventStream, messageId dedupe, and destination debuggers. |
Treat server and browser analytics forwarding as separate handoffs. Server-rendered attribution uses request-local SDK data; hydrated client activity uses a live React Web subscription, not a replay queue. If a tag or analytics library loads after hydration, buffer forwarded payloads in application code with an explicit size, TTL, and drop policy.
For hydrated activity, add the subscription to the client provider wrapper that already mounts
OptimizationRoot:
<OptimizationRoot
clientId={process.env.NEXT_PUBLIC_OPTIMIZATION_CLIENT_ID ?? ''}
environment={process.env.NEXT_PUBLIC_OPTIMIZATION_ENVIRONMENT ?? 'main'}
trackEntryInteraction={{ views: true, clicks: true, hovers: true }}
logLevel="error"
onStatesReady={(states) => {
const forwardedMessageIds = new Set<string>()
const eventSubscription = states.eventStream.subscribe((event) => {
if (!event) return
if (forwardedMessageIds.has(event.messageId)) return
if (!canForwardSdkEvent(event)) return
forwardedMessageIds.add(event.messageId)
analytics.track(`Contentful ${event.type}`, pickContentfulEventProperties(event))
})
return () => eventSubscription.unsubscribe()
}}
>
<Suspense>
<NextAppAutoPageTracker />
</Suspense>
{children}
</OptimizationRoot>
Use Forwarding Optimization SDK context to analytics and tag-management tools for request-local server mapping, React Web subscription helpers, vendor examples, consent, identity, dedupe, and governance guidance.
| User action | Effect on displayed content | When personalization updates |
|---|---|---|
| First page load (anonymous) | Baseline or variant per profile | Immediate (server-resolved) |
| Accept or reject consent | No change to content | Next server request |
Identify (sdk.identify()) |
No change to content | Next server request |
| Navigate to another page | New server-resolved content | Immediate (new SSR) |
| Browser refresh | Server re-resolves with updated profile | Immediate (new SSR) |
The key insight is that client actions update the profile server-side through the Experience API, but the rendered HTML is a snapshot of the profile state at the time of the server request. The next request reflects the updated profile.
This behavior is intentional: the server is the sole source of truth for what content to show. The client never re-resolves entries in this pattern.
The personalization loop makes certain pieces of your response non-cacheable at the CDN or reverse proxy layer:
requestOptimization.page() must not be cached and reused across requests. It
performs a server-side effect and returns profile state for the current visitor.resolveOptimizedEntry()) is only cache-safe if you vary the
cache on both the baseline entry version and a fingerprint of selectedOptimizations. In
practice, most teams treat server-rendered personalized responses as uncacheable.If your deployment uses Next.js full-route caching or generateStaticParams, personalized routes
must be excluded from those caches or must vary on the full profile state.
Keep this boundary strict throughout the application:
@contentful/optimization-node.'use client') import only from @contentful/optimization-react-web.Mixing them causes runtime errors or bundling failures. The most common mistake is importing a React
Web SDK hook at the top of a file that is later resolved as a Server Component. If you see an error
about browser globals in a server context, trace the import chain back to a file missing the
'use client' directive.
A practical rule: any file that imports from @contentful/optimization-react-web must begin with
'use client', or it must only be imported by files that do.
implementations/react-web-sdk+node-sdk_nextjs-ssr:
working Next.js App Router application using the SSR-primary pattern. The server resolves all
entries, the client handles tracking and interactive controls only.
middleware.ts: Edge
Runtime cookie lifecyclelib/optimization-server.ts:
Node SDK singletonapp/page.tsx: Server
Component fetching entries and resolving variantscomponents/ClientProviderWrapper.tsx:
dynamic React Web SDK providercomponents/InteractiveControls.tsx:
Client Component for consent, identify, and reset