Use this guide when you want to personalize a Next.js App Router application where the first page load is server-resolved (no flicker, SEO-friendly) and subsequent client-side interactions such as identify, consent, and profile reset re-resolve entries immediately without a page refresh.
If instant post-identify reactivity is not required and you prefer the simpler mental model where the server is always the sole source of truth, use the SSR-primary guide instead.
The hybrid SSR + CSR takeover pattern uses the same two packages as the SSR-primary pattern, but gives the React Web SDK a more active role after hydration:
@contentful/optimization-node — stateless, server-side. Resolves entry variants before the HTML
response leaves the server. Also used to seed initial optimization state into the client provider.@contentful/optimization-react-web — stateful, browser-side. After hydration, takes over entry
resolution so that profile changes (consent, identify, reset) immediately re-resolve which variant
to render without a server roundtrip.What this setup gives you:
sdk.identify(), sdk.consent(), or sdk.reset()
immediately updates the resolved entries on screen.<Link> navigations resolve
variants client-side.What it does not give you (compared to the SSR-primary pattern):
| Concern | First paint (server) | After hydration (client) |
|---|---|---|
| Profile resolution | Middleware + Server Component (Node SDK) | React Web SDK (automatic on init) |
| Entry resolution | sdk.resolveOptimizedEntry() in Server Component |
resolveEntry() via useEntryResolver() hook |
| Entry fetching | Server-side from Contentful | Client-side from Contentful (for new routes) |
| Page tracking | N/A | NextAppAutoPageTracker fires on route change |
| Interaction tracking | N/A (data attributes rendered server-side) | trackEntryInteraction observes elements |
| Consent / identify / reset | N/A | React Web SDK — triggers immediate re-resolution |
In practice, the integration follows this sequence:
sdk.forRequest(); the examples below use
accepted consent for a default-on policy and pass the result as defaults into the client
provider.sdk.resolveOptimizedEntry() for first-paint content.resolveEntry() from useEntryResolver() for
reactive content.next/dynamic and ssr: false.The hybrid 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, then wrap the consent-allowed request-bound page call in
React's cache() function so that multiple Server Components on the same request share a single API
call:
// lib/optimization-server.ts
import ContentfulOptimization from '@contentful/optimization-node'
import { cookies, headers } from 'next/headers'
import { cache } from 'react'
import { ANONYMOUS_ID_COOKIE } from '@contentful/optimization-node/constants'
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',
})
const getOptimizationData = cache(async (eventLocale: string, contentfulLocale?: string) => {
const cookieStore = await cookies()
const headerStore = await headers()
const anonymousId = cookieStore.get(ANONYMOUS_ID_COOKIE)?.value
const profile = anonymousId ? { id: anonymousId } : undefined
const requestOptimization = sdk.forRequest({
consent: { events: true, persistence: true },
eventContext: {
locale: eventLocale,
userAgent: headerStore.get('user-agent') ?? 'next-js-server',
},
experienceOptions: contentfulLocale ? { locale: contentfulLocale } : undefined,
profile,
})
return requestOptimization.page()
})
export { sdk, getOptimizationData }
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.
cache() is a React Server Component primitive that deduplicates calls within a single render pass.
Both the layout and the page can call getOptimizationData() and only one default-on HTTP request
to the Experience API is made per server request. If application policy depends on user choice, read
that state before the SDK call and return undefined when server personalization is not permitted.
Middleware runs before every request and keeps the anonymous ID cookie aligned with the default-on
server requestOptimization.page() calls:
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 shared between the Node SDK and the React Web SDK. After
hydration, the Web SDK reads the same cookie from document.cookie and continues the same anonymous
profile journey. Do not mark this cookie as HttpOnly. If application policy depends on user
choice, keep that consent state in a CMP, session, account preference, or cookie and clear
ANONYMOUS_ID_COOKIE when profile continuity is not permitted.
In Server Component pages, resolve entries the same way as the SSR-primary pattern:
// app/page.tsx
import { sdk, getOptimizationData } from '@/lib/optimization-server'
import { headers } from 'next/headers'
export default async function Home() {
const headerStore = await headers()
const { contentfulLocale, eventLocale } = sdk.resolveRequestLocale(
headerStore.get('accept-language'),
)
const contentfulOptions = contentfulLocale ? { locale: contentfulLocale } : undefined
const [baselineEntries, optimizationData] = await Promise.all([
fetchEntriesFromContentful(contentfulOptions),
getOptimizationData(eventLocale, contentfulLocale),
])
const resolvedEntries = baselineEntries.map((entry) => {
const { entry: resolved } = sdk.resolveOptimizedEntry(
entry,
optimizationData?.selectedOptimizations,
)
return resolved
})
return (
<main>
<HybridEntryList baselineEntries={baselineEntries} serverResolvedEntries={resolvedEntries} />
</main>
)
}
HybridEntryList is a Client Component (described in step 6) that receives both the baseline
entries and the server-resolved entries. It renders the server-resolved entries immediately, then
switches to the client-resolved versions once the React Web SDK is ready.
Fetch server-rendered baseline entries with the contentfulLocale returned by
resolveRequestLocale() when configured. Configure contentfulLocales.default for single-locale
apps, and add contentfulLocales.supported for localized apps that need request locale matching.
Use the same resolved Contentful locale as the stateless per-call { locale } request option when
it is present. contentful.js withAllLocales and raw CDA locale=* return 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.
Include data-ctfl-entry-id on the wrapper element so the React Web SDK can register interaction
trackers after hydration. Keep the original baseline ID in application-owned metadata such as
data-ctfl-baseline-id when your rendering code needs it:
<div data-ctfl-entry-id={resolvedEntry.sys.id} data-ctfl-baseline-id={baselineEntry.sys.id}>
{resolvedEntry.fields.title}
</div>
data-ctfl-baseline-id is not used in Web SDK 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 key difference from the SSR-primary pattern is passing defaults to OptimizationRoot. This
seeds the client SDK with the profile and selectedOptimizations the server already resolved, which
allows resolveEntry() calls in Client Components to return as soon as the client provider reaches
readiness after hydration. In normal browser rendering, the React Web provider uses layout-effect
initialization so ready children can mount before the first visible client paint.
Create the client wrapper component:
// components/ClientProviderWrapper.tsx
'use client'
import dynamic from 'next/dynamic'
import { Suspense, type ReactNode } from 'react'
import type {
Profile,
SelectedOptimizationArray,
ChangeArray,
} from '@contentful/optimization-react-web/api-schemas'
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 },
)
interface ClientProviderWrapperProps {
children: ReactNode
contentfulLocale?: string
defaults?: {
consent?: boolean
profile?: Profile
selectedOptimizations?: SelectedOptimizationArray
changes?: ChangeArray
}
}
export function ClientProviderWrapper({
children,
contentfulLocale,
defaults,
}: ClientProviderWrapperProps) {
return (
<OptimizationRoot
clientId={process.env.NEXT_PUBLIC_OPTIMIZATION_CLIENT_ID ?? ''}
environment={process.env.NEXT_PUBLIC_OPTIMIZATION_ENVIRONMENT ?? 'main'}
contentfulLocales={{
default: 'en-US',
supported: ['en-US', 'de-DE', 'fr-FR'],
}}
locale={contentfulLocale}
trackEntryInteraction={{ views: true, clicks: true, hovers: true }}
logLevel="error"
defaults={defaults}
>
<Suspense>
<NextAppAutoPageTracker />
</Suspense>
{children}
</OptimizationRoot>
)
}
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.
The layout is a Server Component and can call getOptimizationData() to fetch the optimization
state for the current request. Pass that data to ClientProviderWrapper as defaults:
// app/layout.tsx
import { ClientProviderWrapper } from '@/components/ClientProviderWrapper'
import { getOptimizationData, 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, eventLocale } = sdk.resolveRequestLocale(
headerStore.get('accept-language'),
)
const optimizationData = await getOptimizationData(eventLocale, contentfulLocale)
const htmlLang = getHtmlLang(contentfulLocale)
const defaults = {
consent: true,
...(optimizationData
? {
profile: optimizationData.profile,
selectedOptimizations: optimizationData.selectedOptimizations,
changes: optimizationData.changes,
}
: {}),
}
return (
<html lang={htmlLang}>
<body>
<ClientProviderWrapper contentfulLocale={contentfulLocale} defaults={defaults}>
{children}
</ClientProviderWrapper>
</body>
</html>
)
}
Because getOptimizationData() is wrapped with cache(), calling it in the layout and in a page
Server Component on the same request makes only one API call to the Experience API for the
default-on accepted request policy.
Client Components that need to re-resolve entries when the profile changes subscribe to
selectedOptimizations and call resolveEntry() directly:
// components/HybridEntry.tsx
'use client'
import { useEntryResolver, useOptimizationContext } from '@contentful/optimization-react-web'
import type { SelectedOptimizationArray } from '@contentful/optimization-react-web/api-schemas'
import { useEffect, useState } from 'react'
function HybridEntry({
baselineEntry,
serverResolvedEntry,
}: {
baselineEntry: ContentEntry
serverResolvedEntry: ContentEntry
}) {
const { sdk, isReady } = useOptimizationContext()
const { resolveEntry } = useEntryResolver()
const [selectedOptimizations, setSelectedOptimizations] = useState<
SelectedOptimizationArray | undefined
>(undefined)
useEffect(() => {
if (!sdk || !isReady) return
const subscription = sdk.states.selectedOptimizations.subscribe(setSelectedOptimizations)
return () => subscription.unsubscribe()
}, [sdk, isReady])
const clientReady = isReady && selectedOptimizations !== undefined
const resolvedEntry = clientReady
? resolveEntry(baselineEntry, selectedOptimizations)
: serverResolvedEntry
return (
<div data-ctfl-entry-id={resolvedEntry.sys.id} data-ctfl-baseline-id={baselineEntry.sys.id}>
<p>{resolvedEntry.fields.title}</p>
</div>
)
}
The component uses serverResolvedEntry as the initial render value. This ensures the content
matches what the server rendered while the Web SDK initializes. Once isReady is true and
selectedOptimizations is populated (either from defaults or from the Web SDK's first API call),
the component switches to resolveEntry() for all subsequent resolution.
After this point, calling sdk.identify(), sdk.consent(), or sdk.reset() updates
selectedOptimizations in the SDK state, which triggers the subscribe callback, which updates
selectedOptimizations in component state, which causes resolveEntry() to run again with the
updated data. The result is immediate content re-resolution without a server roundtrip.
The subscription in the example above is the low-level imperative approach. It gives you full control over when to switch from server to client resolution, but requires more component state management.
For pages or components where all entries should be client-side reactive (for example, an
interactive dashboard that does not need the SSR-first-paint guarantee), use OptimizedEntry with
liveUpdates enabled instead:
'use client'
import { OptimizedEntry } from '@contentful/optimization-react-web'
function ReactiveSection({ baselineEntry }) {
return (
<OptimizedEntry baselineEntry={baselineEntry} liveUpdates={true}>
{(resolved) => <Card entry={resolved} />}
</OptimizedEntry>
)
}
OptimizedEntry with liveUpdates={true} continuously re-resolves when selectedOptimizations
changes. This is the right choice for sections of the page that do not need the SSR handoff logic.
With a default-on policy, no consent UI is required. The server examples above bind
consent: { events: true, persistence: true }, and the browser provider receives
defaults={{ consent: true, ...serverResolvedState }} 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 reads SDK state and exposes 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>
)
}
In this pattern, unlike the SSR-primary pattern, calling sdk.identify() or sdk.consent()
immediately updates selectedOptimizations in the client SDK, which causes all Client Components
subscribed to that state to re-render with the new variant. No page refresh is required.
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.
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 | Hybrid SSR + CSR handoff |
|---|---|
| Server-resolved first-paint attribution | Use request-local OptimizationData before passing defaults to the client. |
| Hydrated page, entry, and reactive updates | 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-resolved 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 client-side activity after hydration, add the subscription to the client provider wrapper that
already seeds OptimizationRoot with server-resolved defaults:
<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"
defaults={defaults}
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 it takes effect |
|---|---|---|
| First page load | Server-resolved personalized HTML | Immediate (in HTML) |
| After hydration (same page) | No change — server content stays until SDK is ready | Seamless |
| Accept or reject consent | Client Components re-resolve with updated profile | Instant (client-side) |
Identify (sdk.identify()) |
Client Components re-resolve with updated profile | Instant (client-side) |
Reset (sdk.reset()) |
Client Components re-resolve with updated profile | Instant (client-side) |
Navigate via <Link> |
New page entries resolved client-side | Fast (no server roundtrip) |
| Browser refresh or full navigation | Back to server-resolved first paint | Immediate (new SSR) |
OptimizationRoot always initializes a fresh Web SDK instance that calls the Experience API to
fetch selectedOptimizations. This is the same data the server already resolved and passed as
defaults.
The defaults prop seeds the initial state so that resolveEntry() works immediately on first
render. However, the Web SDK still makes its own API call in the background to establish a live,
reactive state for subsequent profile changes.
Impact: There is a brief window after hydration where both server-resolved defaults and the
client's own API call may be in flight simultaneously. The defaults prop ensures content appears
correct during this window (no flicker), but be aware that the client API call will overwrite the
default state once it resolves.
Mitigation: If the Experience API call latency is a concern, test whether the defaults prop
fully prevents any visible content change. In most cases, because the server and client resolve
against the same profile (via the shared cookie), the API call returns the same
selectedOptimizations and the effective displayed content is identical.
The App Router lets you choose the strategy per-route. You can use the Node SDK for Server Component routes and the React Web SDK for Client Component routes within the same application:
sdk.resolveOptimizedEntry() for first-paint content. Best for routes where personalization
decisions are based on stable profile traits, not real-time interactions.identify or consent changes must be reflected immediately. Use resolveEntry() or
OptimizedEntry with liveUpdates={true}.Mixed-strategy applications are valid and can use a single ClientProviderWrapper in the root
layout to provide the React Web SDK to all client-component subtrees.
As with the SSR-primary pattern, keep this boundary strict:
@contentful/optimization-node.'use client') import only from @contentful/optimization-react-web.Any file that imports from @contentful/optimization-react-web must begin with 'use client', or
it must only be imported by files that do. Importing the React Web SDK in a Server Component causes
runtime errors because the SDK accesses browser globals at import time.
implementations/react-web-sdk+node-sdk_nextjs-ssr-csr:
working Next.js App Router application using the hybrid SSR + CSR takeover pattern.
middleware.ts:
Edge Runtime cookie lifecyclelib/optimization-server.ts:
Node SDK singleton with cache()-wrapped getOptimizationData()app/layout.tsx:
Server Component layout that fetches defaults and passes them to the client providerapp/page.tsx:
Server Component page resolving entries for first paintcomponents/ClientProviderWrapper.tsx:
dynamic React Web SDK provider with defaults propcomponents/HybridEntryList.tsx:
Client Component switching between server-resolved and client-resolved entries