Use this guide when you want a Next.js App Router route to personalize the initial HTML with server optimization state, then let the browser take over entry resolution after server-to-browser state handoff when consent, identity, profile, preview, or route state changes.
This pattern uses @contentful/optimization-nextjs. The server entry composes the stateless Node
SDK, the client entry composes the React Web SDK, and the request handler forwards sanitized Next.js
proxy or middleware request context headers. Your application still owns Contentful fetching,
consent policy, identity policy, routing, caching, and component rendering.
If browser-side actions do not need to change visible content until the next request, use the Next.js SSR guide instead.
This quick start assumes your application policy permits accepted SDK startup. It uses the Server Component helper as the only initial page-event owner so the route can render personalized HTML before browser takeover. If consent depends on a consent management platform (CMP), regional rule, account preference, or user choice, keep the same structure and apply the policy-dependent consent section before release.
Install the Next.js adapter package.
Copy this:
pnpm add @contentful/optimization-nextjs
Create one server SDK singleton and a cached request helper that returns server optimization state for the current request. The Next.js proxy or middleware captures request context for this helper.
Copy this:
// lib/optimization-server.ts
import {
createNextjsOptimization,
getNextjsServerOptimizationData,
} from '@contentful/optimization-nextjs/server'
import { cookies, headers } from 'next/headers'
import { cache } from 'react'
export const APP_LOCALE = 'en-US'
// Keep one server SDK instance; bind request state through adapter helpers.
export const optimization = createNextjsOptimization({
clientId: process.env.CONTENTFUL_OPTIMIZATION_CLIENT_ID ?? '',
environment: process.env.CONTENTFUL_OPTIMIZATION_ENVIRONMENT ?? 'main',
locale: APP_LOCALE,
api: {
experienceBaseUrl: process.env.CONTENTFUL_EXPERIENCE_API_BASE_URL,
insightsBaseUrl: process.env.CONTENTFUL_INSIGHTS_API_BASE_URL,
},
app: {
name: 'my-next-app',
version: '1.0.0',
},
logLevel: 'error',
})
export const getOptimizationData = cache(async () => {
const [cookieStore, headerStore] = await Promise.all([cookies(), headers()])
// Accepted startup allows the server page call and returns profile data for browser handoff.
const { data } = await getNextjsServerOptimizationData(optimization, {
consent: { events: true, persistence: true },
cookies: cookieStore,
headers: headerStore,
locale: APP_LOCALE,
})
return data
})
Create a Client Component boundary that renders server content outside the SDK context, then reveals the browser takeover island after browser SDK state is ready.
Adapt this to your use case:
// components/HybridTakeoverBoundary.tsx
'use client'
import {
NextAppAutoPageTracker,
OptimizationRoot,
type NextAppAutoPageTrackerProps,
type OptimizationRootProps,
} from '@contentful/optimization-nextjs/client'
import { Suspense, useState, type ReactNode } from 'react'
export function HybridTakeoverBoundary({
children,
defaults,
initialPageEvent,
locale,
serverContent,
serverOptimizationState,
}: {
children: ReactNode
defaults: OptimizationRootProps['defaults']
initialPageEvent: NextAppAutoPageTrackerProps['initialPageEvent']
locale: string
serverContent: ReactNode
serverOptimizationState: OptimizationRootProps['serverOptimizationState']
}) {
const [takeoverReady, setTakeoverReady] = useState(false)
return (
<>
<div hidden={takeoverReady}>{serverContent}</div>
<OptimizationRoot
clientId={process.env.NEXT_PUBLIC_CONTENTFUL_OPTIMIZATION_CLIENT_ID ?? ''}
environment={process.env.NEXT_PUBLIC_CONTENTFUL_OPTIMIZATION_ENVIRONMENT ?? 'main'}
locale={locale}
defaults={defaults}
serverOptimizationState={serverOptimizationState}
logLevel="error"
onStatesReady={() => {
// Reveal browser takeover only after browser SDK state is ready.
setTakeoverReady(true)
}}
>
<Suspense>
{/* Skip only when this route's server helper emitted the initial page event. */}
<NextAppAutoPageTracker initialPageEvent={initialPageEvent} />
</Suspense>
<div hidden={!takeoverReady}>{children}</div>
</OptimizationRoot>
</>
)
}
Fetch single-locale Contentful entries in a Server Component, resolve the initial HTML with the server data, and pass both baseline and server-resolved data to the takeover island.
Adapt this to your use case:
// app/page.tsx
import { HybridEntryList } from '@/components/HybridEntryList'
import { HybridTakeoverBoundary } from '@/components/HybridTakeoverBoundary'
import { fetchEntriesFromContentful } from '@/lib/contentful-client'
import { APP_LOCALE, getOptimizationData, optimization } from '@/lib/optimization-server'
import type { Entry } from 'contentful'
function EntryCard({ entry }: { entry: Entry }) {
return <h2>{String(entry.fields.title ?? '')}</h2>
}
function ServerEntryList({ entries }: { entries: Entry[] }) {
return (
<>
{entries.map((entry) => (
<EntryCard key={entry.sys.id} entry={entry} />
))}
</>
)
}
export default async function Home() {
const [baselineEntries, optimizationData] = await Promise.all([
fetchEntriesFromContentful(['home-hero', 'home-offer']),
getOptimizationData(),
])
// Resolve locally against request-selected optimizations, with baseline fallback.
const serverResolvedData = baselineEntries.map((baselineEntry) =>
optimization.resolveOptimizedEntry(baselineEntry, optimizationData?.selectedOptimizations),
)
const serverEntries = serverResolvedData.map(({ entry }) => entry)
const defaults = { consent: true }
return (
<HybridTakeoverBoundary
defaults={defaults}
initialPageEvent={optimizationData ? 'skip' : 'emit'}
locale={APP_LOCALE}
serverContent={<ServerEntryList entries={serverEntries} />}
serverOptimizationState={optimizationData}
>
<HybridEntryList
baselineEntries={baselineEntries}
serverResolvedData={serverResolvedData}
/>
</HybridTakeoverBoundary>
)
}
Render the browser takeover entries through OptimizedEntry. Use the server-resolved entry as
the loading fallback so takeover content matches the initial HTML while the browser SDK becomes
ready.
Adapt this to your use case:
// components/HybridEntryList.tsx
'use client'
import { OptimizedEntry } from '@contentful/optimization-nextjs/client'
import type { Entry } from 'contentful'
function EntryCard({ entry }: { entry: Entry }) {
return <h2>{String(entry.fields.title ?? '')}</h2>
}
export function HybridEntryList({
baselineEntries,
serverResolvedData,
}: {
baselineEntries: Entry[]
serverResolvedData: { entry: Entry }[]
}) {
return (
<>
{baselineEntries.map((baselineEntry, index) => {
const serverEntry = serverResolvedData[index]?.entry ?? baselineEntry
// loadingFallback matches the server-selected content during browser takeover.
return (
<OptimizedEntry
key={baselineEntry.sys.id}
baselineEntry={baselineEntry}
loadingFallback={<EntryCard entry={serverEntry} />}
>
{(resolvedEntry) => <EntryCard entry={resolvedEntry} />}
</OptimizedEntry>
)
})}
</>
)
}
Verify the first run. The route source must contain the server-selected content or baseline
fallback, that content must remain visible after browser takeover, and the browser must not emit
a duplicate initial page event when initialPageEvent="skip" is used with server optimization
state.
Use this table as the setup inventory for the full hybrid integration:
| Setup item | Category | Required for quick start | Where to configure |
|---|---|---|---|
| Next.js App Router with React and React DOM peer dependencies | Required for first integration | Yes | Application package.json |
@contentful/optimization-nextjs package |
Required for first integration | Yes | Application package manager |
| Optimization client ID and environment | Required for first integration | Yes | Server SDK config and client takeover OptimizationRoot props |
| Server-only and browser-exposed environment variables | Required for first integration | Yes | Runtime environment, including NEXT_PUBLIC_ variables for browser config |
| Contentful CDA credentials and app-owned fetcher | Required for first integration | Yes | Application Contentful client |
| Single-locale CDA entries with resolved optimization links | Required for first integration | Yes | CDA calls with one locale and enough include depth, commonly include: 10 |
| Server request helper for Optimization data | Required for first integration | Yes | Server-only page-event owner using getNextjsServerOptimizationData() and cache() |
| Next.js proxy or middleware hook | Common but policy-dependent | No | proxy.ts or middleware.ts for server helpers that need request context |
Browser takeover boundary with OptimizationRoot |
Required for first integration | Yes | Client boundary around takeover content, not around server-rendered initial HTML |
| Server-resolved entry data or baseline fallback | Required for first integration | Yes | Server Components before passing entries to browser takeover components |
Client takeover with OptimizedEntry or useOptimizedEntry() |
Required for first integration | Yes | Client Components that render personalized entries after state handoff |
| App Router page tracker | Required for first integration | Yes | NextAppAutoPageTracker under OptimizationRoot |
| Consent and persistence policy | Common but policy-dependent | Conditional | Server calls, browser consent defaults, CMP, or controls |
| Anonymous ID cookie continuity | Common but policy-dependent | Conditional | Server helper cookies, browser state handoff, ESR persistence, and ctfl-opt-aid |
| Browser identify, profile state, and reset controls | Common but policy-dependent | No | Client Components using Next.js client hooks |
| Entry interaction tracking for views, clicks, and hovers | Common but policy-dependent | No | trackEntryInteraction, OptimizedEntry props, or server tracking attributes |
| Analytics or tag-manager forwarding | Optional | No | OptimizationRoot onStatesReady subscription and app-owned forwarding code |
| Merge tag and Custom Flag rendering | Optional | No | Rich Text renderers, flag readers, and live-update components |
| Preview panel package | Optional | No | Environment-gated preview attachment in non-production app environments |
| Strict pre-consent allowlist, storage, queue, and cookie policy | Advanced or production-only | No | SDK config, server helper consent, CMP integration, and application cleanup |
| Personalized response caching and duplicate-event policy | Advanced or production-only | No | Next.js route config, CDN rules, server helper structure, and tracker settings |
Use one application Contentful locale for entries that feed SDK resolution. The SDK Experience and event locale often uses the same string, but the SDK does not fetch Contentful content or change CDA requests for you.
Integration category: Required for first integration
The Next.js adapter is a glue package. Keep application imports on the adapter subpaths instead of importing lower-level Node, Web, or React Web packages directly.
| Import path | Runtime | Responsibility |
|---|---|---|
@contentful/optimization-nextjs/server |
Server Components and server-only modules | Server SDK creation, request binding, server resolution, server-owned page calls, and SSR tracking attributes |
@contentful/optimization-nextjs/esr |
Route handlers, edge functions, and ESR flows | Request-rendered Optimization data and explicit response persistence |
@contentful/optimization-nextjs/request-handler |
Next.js proxy or middleware | Request URL capture and SDK-owned request header sanitization |
@contentful/optimization-nextjs/client |
Client Components and takeover islands | React provider, hooks, entry primitives, live updates, and Next.js page route tracker |
@contentful/optimization-nextjs/api-schemas |
Shared schema helpers | API types plus structural guards such as isMergeTagEntry, isRichTextDocument, and isResolvedContentfulEntry |
@contentful/optimization-nextjs in the Next.js app./server subpath in server-only modules and Server
Components.OptimizationRoot, hooks, OptimizedEntry, and router trackers only from the /client
subpath in Client Components or layouts rendering Client Components.createNextjsOptimizationContextHandler() only from the /request-handler subpath in
proxy or middleware code./api-schemas subpath, not from /client.'use client'.Copy this:
pnpm add @contentful/optimization-nextjs
The adapter package lists Next.js, React, and React DOM as application-owned peer dependencies. It uses the runtime that is already installed by the app.
Integration category: Required for first integration
Create the Next.js server SDK once at module level, then bind request-specific cookies, headers, consent, locale, and profile through request helpers. Use the proxy or middleware context helper so Server Components can derive page context from forwarded request context headers.
getNextjsServerOptimizationData() in React cache() when more than one Server Component
in the same render pass needs the same request-local Optimization data.undefined instead of calling the SDK when application policy does not permit server
personalization for the request.Adapt this to your use case:
// lib/optimization-server.ts
import {
createNextjsOptimization,
getNextjsServerOptimizationData,
} from '@contentful/optimization-nextjs/server'
import { cookies, headers } from 'next/headers'
import { cache } from 'react'
export const APP_LOCALE = 'en-US'
const APP_PERSONALIZATION_CONSENT_COOKIE = 'app-personalization-consent'
// Keep one server SDK instance; bind request state through adapter helpers.
export const optimization = createNextjsOptimization({
clientId: process.env.CONTENTFUL_OPTIMIZATION_CLIENT_ID ?? '',
environment: process.env.CONTENTFUL_OPTIMIZATION_ENVIRONMENT ?? 'main',
locale: APP_LOCALE,
api: {
experienceBaseUrl: process.env.CONTENTFUL_EXPERIENCE_API_BASE_URL,
insightsBaseUrl: process.env.CONTENTFUL_INSIGHTS_API_BASE_URL,
},
app: {
name: 'my-next-app',
version: '1.0.0',
},
logLevel: 'error',
})
export const getOptimizationData = cache(async () => {
const [cookieStore, headerStore] = await Promise.all([cookies(), headers()])
const appConsent = cookieStore.get(APP_PERSONALIZATION_CONSENT_COOKIE)?.value
if (appConsent === 'denied') return undefined
// Accepted startup allows the server page call and returns profile data for browser handoff.
const { data } = await getNextjsServerOptimizationData(optimization, {
consent: { events: true, persistence: true },
cookies: cookieStore,
headers: headerStore,
locale: APP_LOCALE,
})
return data
})
getNextjsServerOptimizationData() calls page() through the request-bound Node SDK and returns
the profile, selected optimizations, and changes for that request. Treat that call as the initial
server page event for the route. The request-scoped locale is sent to the Experience API and used
as the default event context locale. In Server Components, pass headers() so the SDK can derive
page context from the request URL captured by the Next.js proxy or middleware helper.
Integration category: Common but policy-dependent
Use a Next.js proxy or middleware handler to capture request context before Server Components call
getNextjsServerOptimizationData().
proxy.ts or middleware.ts.getNextjsServerOptimizationData().Adapt this to your use case:
// proxy.ts
import { createNextjsOptimizationContextHandler } from '@contentful/optimization-nextjs/request-handler'
export const proxy = createNextjsOptimizationContextHandler()
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|api).*)'],
}
The context handler strips incoming SDK-owned request headers and forwards sanitized request context headers including the SDK-owned request URL header for the server SDK helper.
Do not mark ctfl-opt-aid as HttpOnly when the browser SDK must adopt it through browser state
handoff or an explicit server persistence flow. For deeper mechanics, see
Profile synchronization between client and server.
Integration category: Required for first integration
The SDK does not fetch Contentful entries. Fetch baseline entries in the application layer with one Contentful locale and resolved optimization links before passing them to the server resolver or client entry primitives.
include: 10.contentful.js withAllLocales or raw CDA locale=*
into resolveOptimizedEntry(), OptimizedEntry, or useOptimizedEntry().Adapt this to your use case:
import { createClient, type Entry } from 'contentful'
import { APP_LOCALE } from './optimization-server'
const contentfulClient = createClient({
accessToken: process.env.CONTENTFUL_DELIVERY_TOKEN ?? '',
environment: process.env.CONTENTFUL_ENVIRONMENT ?? 'main',
space: process.env.CONTENTFUL_SPACE_ID ?? '',
})
export function getContentfulClient() {
return contentfulClient
}
export async function fetchEntriesFromContentful(entryIds: readonly string[]): Promise<Entry[]> {
const entries = await Promise.all(
entryIds.map((entryId) =>
getContentfulClient().getEntry(entryId, {
// Entry resolution expects linked optimization data in one locale.
include: 10,
locale: APP_LOCALE,
}),
),
)
return entries
}
Single-locale entries expose optimization fields as direct field values, such as
fields.nt_experiences and fields.nt_variants. All-locale responses use locale-keyed field maps
and fall back to baseline rendering. For the resolver contract, see
Entry personalization and variant resolution.
For the full locale model, see
Locale handling in the Optimization SDK Suite.
Integration category: Required for first integration
Use the server SDK result for the content decision that starts the route. The resolver is local and
synchronous: it joins the current selectedOptimizations with optimization links already present in
the Contentful payload.
optimization.resolveOptimizedEntry() for each baseline entry.OptimizationRoot boundary when the initial HTML must
contain personalized content.Adapt this to your use case:
// app/page.tsx
import { HybridEntryList } from '@/components/HybridEntryList'
import { HybridTakeoverBoundary } from '@/components/HybridTakeoverBoundary'
import { fetchEntriesFromContentful } from '@/lib/contentful-client'
import { APP_LOCALE, getOptimizationData, optimization } from '@/lib/optimization-server'
import type { Entry } from 'contentful'
function ServerEntryList({ entries }: { entries: Entry[] }) {
return (
<>
{entries.map((entry) => (
<h2 key={entry.sys.id}>{String(entry.fields.title ?? '')}</h2>
))}
</>
)
}
export default async function Home() {
const [baselineEntries, optimizationData] = await Promise.all([
fetchEntriesFromContentful(['home-hero', 'home-offer']),
getOptimizationData(),
])
// Resolve locally against request-selected optimizations, with baseline fallback.
const serverResolvedData = baselineEntries.map((baselineEntry) =>
optimization.resolveOptimizedEntry(baselineEntry, optimizationData?.selectedOptimizations),
)
const serverEntries = serverResolvedData.map(({ entry }) => entry)
const defaults = { consent: true }
return (
<HybridTakeoverBoundary
defaults={defaults}
initialPageEvent={optimizationData ? 'skip' : 'emit'}
locale={APP_LOCALE}
serverContent={<ServerEntryList entries={serverEntries} />}
serverOptimizationState={optimizationData}
>
<HybridEntryList baselineEntries={baselineEntries} serverResolvedData={serverResolvedData} />
</HybridTakeoverBoundary>
)
}
When a route renders entry HTML without client takeover, wrap the resolved entry with
ServerOptimizedEntry from the server entrypoint so the browser interaction tracker can read the
same data-ctfl-* metadata after browser startup.
Follow this pattern:
import { ServerOptimizedEntry } from '@contentful/optimization-nextjs/server'
function ServerRenderedEntry({ baselineEntry, resolvedData }: ServerRenderedEntryProps) {
return (
// Render data-ctfl-* attributes for browser entry-interaction tracking.
<ServerOptimizedEntry baselineEntry={baselineEntry} resolvedData={resolvedData}>
<h2>{String(resolvedData.entry.fields.title ?? '')}</h2>
</ServerOptimizedEntry>
)
}
Integration category: Required for first integration
The SDK context comes from OptimizationRoot or OptimizationProvider through the React Web layer.
The browser SDK can initialize after the first server render, so the takeover island can return
null until SDK state is ready. When initial HTML personalization is required, render the
server-resolved content outside that provider boundary and pass server-returned Optimization data as
serverOptimizationState to the takeover island.
serverOptimizationState on OptimizationRoot or OptimizationProvider.defaults for configuration or default state such as consent policy.OptimizationRoot.OptimizationRoot around the Client Components that take over after state handoff.NEXT_PUBLIC_CONTENTFUL_OPTIMIZATION_CLIENT_ID into the
client provider.defaults.consent: true only when application policy permits accepted browser startup.NextAppAutoPageTracker in Suspense because it uses App Router navigation hooks.Adapt this to your use case:
'use client'
import {
NextAppAutoPageTracker,
OptimizationRoot,
type NextAppAutoPageTrackerProps,
type OptimizationRootProps,
} from '@contentful/optimization-nextjs/client'
import { Suspense, useState, type ReactNode } from 'react'
export function HybridTakeoverBoundary({
children,
defaults,
initialPageEvent,
locale,
serverContent,
serverOptimizationState,
}: {
children: ReactNode
defaults: OptimizationRootProps['defaults']
initialPageEvent: NextAppAutoPageTrackerProps['initialPageEvent']
locale: string
serverContent: ReactNode
serverOptimizationState: OptimizationRootProps['serverOptimizationState']
}) {
const [takeoverReady, setTakeoverReady] = useState(false)
return (
<>
<div hidden={takeoverReady}>{serverContent}</div>
<OptimizationRoot
clientId={process.env.NEXT_PUBLIC_CONTENTFUL_OPTIMIZATION_CLIENT_ID ?? ''}
environment={process.env.NEXT_PUBLIC_CONTENTFUL_OPTIMIZATION_ENVIRONMENT ?? 'main'}
locale={locale}
defaults={defaults}
serverOptimizationState={serverOptimizationState}
onStatesReady={() => {
// Provider children mount only after the browser SDK exists.
setTakeoverReady(true)
}}
>
<Suspense>
<NextAppAutoPageTracker initialPageEvent={initialPageEvent} />
</Suspense>
<div hidden={!takeoverReady}>{children}</div>
</OptimizationRoot>
</>
)
}
If a route only needs server-seeded browser takeover, and does not need personalized initial HTML,
it can render takeover content directly under OptimizationRoot with a loading fallback. That path
does not make a server-personalized HTML promise. The hybrid path in this guide renders the initial
content outside the provider, then lets browser-side identify(), reset(), track(), route page
events, and live updates call the APIs after browser startup.
Integration category: Required for first integration
Render takeover content with React Web entry primitives from the Next.js client entrypoint.
liveUpdates defaults to false, so set it globally on OptimizationRoot or per OptimizedEntry
when visible content must react to profile changes.
OptimizedEntry.liveUpdates={true} for entries that must update after identify(), consent(),
reset(), preview changes, or selected-optimization state changes.loadingFallback when you need the first rendered content to
stay stable while the browser SDK becomes ready.useOptimizedEntry() or useEntryResolver() only when a component needs custom rendering
control that the OptimizedEntry wrapper does not provide.Adapt this to your use case:
'use client'
import { OptimizedEntry } from '@contentful/optimization-nextjs/client'
import type { Entry } from 'contentful'
function EntryCard({ entry }: { entry: Entry }) {
return <article>{String(entry.fields.title ?? '')}</article>
}
export function HybridEntry({
baselineEntry,
serverResolvedEntry,
}: {
baselineEntry: Entry
serverResolvedEntry: Entry
}) {
// liveUpdates keeps this entry reactive after identify, consent, reset, or preview changes.
// loadingFallback preserves server-selected content while the browser SDK initializes.
return (
<OptimizedEntry
baselineEntry={baselineEntry}
liveUpdates={true}
loadingFallback={<EntryCard entry={serverResolvedEntry} />}
>
{(resolvedEntry) => <EntryCard entry={resolvedEntry} />}
</OptimizedEntry>
)
}
For shared live-update controls, set liveUpdates={true} on OptimizationRoot or wrap a subtree in
LiveUpdatesProvider. Per-entry liveUpdates={true} or liveUpdates={false} overrides the global
setting for that component.
Verify live re-resolution only after the takeover path is working:
liveUpdates={true} globally or on the entries that must react to browser state changes.identify(), consent(), or reset() from the Client Component that owns the
application control.liveUpdates={false} stay locked until the next render path.Integration category: Required for first integration
Choose one server owner for the initial route page event, then use NextAppAutoPageTracker for
browser App Router route changes.
getNextjsServerOptimizationData() as the owner when the route needs returned
selectedOptimizations for personalized initial HTML.NextAppAutoPageTracker under OptimizationRoot.initialPageEvent="skip" only when the server helper already emitted a page event for the
same initial route.initialPageEvent="emit" when the server did not request Optimization data, such as denied
consent, browser-owned islands, or routes where the server helper returned undefined.pagePayload on the server helper when your initial route event needs app-specific
properties.Follow this pattern:
<Suspense>
{/* Skip only when the selected server owner emitted this route's initial page event. */}
<NextAppAutoPageTracker
initialPageEvent={optimizationData ? 'skip' : 'emit'}
getPagePayload={({ pathname }) => ({
properties: {
routeGroup: pathname.startsWith('/account') ? 'account' : 'public',
},
})}
/>
</Suspense>
The tracker deduplicates consecutive route keys, including Strict Mode double-effect invocations,
but it does not replace application policy. Use the skip mode only for a matching server page
event.
Integration category: Common but policy-dependent
Entry interaction tracking is browser-side. The server or React component renders metadata, and the browser SDK observes views, clicks, and hovers after consent permits the detectors and event delivery.
trackEntryInteraction only to opt out of interaction types the app must not observe.OptimizedEntry props such as clickable, trackViews, trackClicks, trackHovers,
viewDurationUpdateIntervalMs, and hoverDurationUpdateIntervalMs for per-entry control.ServerOptimizedEntry for server-rendered entries that need the same tracking metadata.sdk.tracking.enableElement(...) from useOptimization() only for app-owned manual
observation cases.allowedEventTypes permits them.Follow this pattern:
<OptimizationRoot
clientId={process.env.NEXT_PUBLIC_CONTENTFUL_OPTIMIZATION_CLIENT_ID ?? ''}
// Opt out only when your consent policy disallows a detector.
trackEntryInteraction={{ hovers: false }}
>
{children}
</OptimizationRoot>
Follow this pattern:
<OptimizedEntry baselineEntry={entry} clickable hoverDurationUpdateIntervalMs={1000}>
{(resolvedEntry) => <EntryCard entry={resolvedEntry} />}
</OptimizedEntry>
The tracking payload uses the resolved entry ID, not the baseline entry ID. For deeper mechanics, see Interaction tracking in Web SDKs.
Integration category: Common but policy-dependent
Consent, identity, and profile continuity are application policy decisions. The SDK provides the runtime controls, but your application owns the consent record, privacy notice, CMP integration, identity source, and server cookie cleanup.
consent()
from the Client Component that owns the browser decision.identify() from browser flows when a visitor becomes known.reset() and clear application-owned profile cookies when withdrawal or sign-out must end
active-session personalization.Adapt this to your use case:
'use client'
import {
useConsentState,
useOptimizationActions,
useProfileState,
} from '@contentful/optimization-nextjs/client'
import { useEffect, useMemo } from 'react'
const APP_PERSONALIZATION_CONSENT_COOKIE = 'app-personalization-consent'
function setAppConsentCookie(consented: boolean): void {
const value = consented ? 'granted' : 'denied'
// Store app consent where the server helper can read it on the next request.
document.cookie = `${APP_PERSONALIZATION_CONSENT_COOKIE}=${value}; Path=/; SameSite=Lax`
}
export function PersonalizationControls() {
const { consent: setConsent, identify, reset } = useOptimizationActions()
const consent = useConsentState()
const profile = useProfileState()
useEffect(() => {
if (typeof consent === 'boolean') setAppConsentCookie(consent)
}, [consent])
const isIdentified = useMemo(
() => profile !== undefined && Boolean(profile.traits.identified),
[profile],
)
return (
<div>
<button onClick={() => setConsent(consent !== true)} type="button">
{consent === true ? 'Reject consent' : 'Accept consent'}
</button>
{!isIdentified ? (
<button
onClick={() => {
void identify({ userId: 'user-123', traits: { identified: true } })
}}
type="button"
>
Identify
</button>
) : (
<button onClick={() => reset()} type="button">
Reset profile
</button>
)}
</div>
)
}
With liveUpdates={true}, identify(), consent(), and reset() can change
selectedOptimizations in the browser SDK and re-render affected entries without a full page
refresh. For consent design details, see
Consent management in the Optimization SDK Suite.
Integration category: Optional
Use analytics forwarding when your app needs to send approved Optimization context to a tag manager, customer-data platform, warehouse, or analytics destination. The SDK still sends events to Contentful; forwarding is application-owned.
onStatesReady so event observers attach before child
effects such as route trackers emit events.messageId or destination-specific semantic keys.messageId
before subscribing and skip that event.Adapt this to your use case:
const forwardedMessageIds = new Set<string>()
<OptimizationRoot
clientId={process.env.NEXT_PUBLIC_CONTENTFUL_OPTIMIZATION_CLIENT_ID ?? ''}
onStatesReady={(states) => {
// Subscribe before child effects, such as route trackers, emit events.
const initialMessageId = states.eventStream.current?.messageId
const eventSubscription = states.eventStream.subscribe((event) => {
if (!event) return
if (forwardedMessageIds.has(event.messageId)) return
if (event.messageId === initialMessageId) {
forwardedMessageIds.add(event.messageId)
return
}
if (!canForwardSdkEvent(event)) return
forwardedMessageIds.add(event.messageId)
analytics.track(`Contentful ${event.type}`, pickContentfulEventProperties(event))
})
const blockedSubscription = states.blockedEventStream.subscribe((blockedEvent) => {
if (blockedEvent) diagnostics.recordBlockedOptimizationEvent(blockedEvent)
})
return () => {
eventSubscription.unsubscribe()
blockedSubscription.unsubscribe()
}
}}
>
{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, dedupe, and governance guidance.
Integration category: Optional
Use merge tags and Custom Flags when entries or components render profile-backed values that are not entry replacements.
location.city or location.country.Follow this pattern:
'use client'
import { useMergeTagResolver } from '@contentful/optimization-nextjs/client'
function MergeTagText({ mergeTagEntry }: { mergeTagEntry: unknown }) {
const { getMergeTagValue } = useMergeTagResolver()
// Resolve against the current browser profile state.
return <span>{getMergeTagValue(mergeTagEntry) ?? ''}</span>
}
Merge tags and entry replacement use different mechanics. Entry replacement uses
selectedOptimizations; merge tags read profile-backed values from current SDK state.
Integration category: Optional
Use the preview panel where authors or engineers need to inspect variant behavior. Keep production loading explicit and gate attachment behind an application-owned flag.
OptimizationRoot.PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL
to true.Follow this pattern:
'use client'
import { useOptimizationContext } from '@contentful/optimization-nextjs/client'
import { useEffect } from 'react'
export function PreviewPanelAttachment({ nonce }: { nonce?: string }) {
const { isReady } = useOptimizationContext()
const previewPanelEnabled = process.env.PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL === 'true'
useEffect(() => {
// Keep authoring tooling opt-in.
if (!previewPanelEnabled) return
if (!isReady) return
void Promise.all([
import('@contentful/optimization-web-preview-panel'),
import('@/lib/contentful-client'),
])
.then(async ([{ default: attachOptimizationPreviewPanel }, { getContentfulClient }]) => {
await attachOptimizationPreviewPanel({
contentful: getContentfulClient(),
nonce,
})
})
.catch(() => undefined)
}, [isReady, nonce, previewPanelEnabled])
return null
}
The preview panel package has no attachment side effects. A dynamic import only loads the attach
function; the application must call attachOptimizationPreviewPanel(...) with the Contentful client
that can read previewable entries.
The hybrid reference implementation exposes PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL through
Next.js config and attaches the preview panel only when that flag is true.
Integration category: Advanced or production-only
App Router applications can mix route strategies. Choose the strategy per route instead of forcing one rendering model across the whole app.
| Route need | Use this pattern |
|---|---|
| Server is the only content source until the next request | Use the SSR guide and render with server helpers only |
| Server first render plus browser-side reactivity | Use this hybrid guide with server optimization state and OptimizedEntry |
| Browser-owned personalization after startup | Render baseline or loading UI on the server and let the browser own it |
| Highly interactive account, dashboard, or settings surfaces | Prefer Client Components with live updates and explicit consent state |
identify(), consent(), reset(), live
flag state, or manual browser tracking.OptimizationRoot for browser takeover subtrees that share browser SDK state.The important boundary is ownership: server routes render from request-local Optimization data, and browser takeover components render from browser SDK state seeded by server optimization state and updated by later browser events.
Integration category: Advanced or production-only
Personalized server rendering is request-specific. Keep shared caches on raw Contentful payloads, not on profile-evaluated SDK results or personalized HTML unless your cache key varies on every personalization input.
cache() to deduplicate getNextjsServerOptimizationData() inside one React Server
Component render pass.getNextjsServerOptimizationData() across requests. It emits a page
event and returns profile-specific data.generateStaticParams, route segment cache settings, CDN
caching, or reverse proxy caching.Copy this:
// app/layout.tsx or app/page.tsx
export const dynamic = 'force-dynamic'
The exact Next.js cache policy belongs to the application. The SDK does not mark routes dynamic for you.
Integration category: Advanced or production-only
Strict consent and duplicate-event controls are production policy work. Configure them only after your privacy, analytics, and platform owners agree on the event posture.
allowedEventTypes: [] when no SDK events can emit before consent.ctfl-opt-aid and application-owned consent or profile cookies when withdrawal must end
profile continuity.initialPageEvent="skip" only for a matching server page event. Use emit when the browser
owns the first page event.states.blockedEventStream during validation to confirm the SDK blocks the events
your policy expects it to block.Adapt this to your use case:
export async function getOptimizationData() {
const [cookieStore, headerStore] = await Promise.all([cookies(), headers()])
const granted = cookieStore.get('app-personalization-consent')?.value === 'granted'
if (!granted) return undefined
return await getNextjsServerOptimizationData(optimization, {
consent: { events: true, persistence: true },
cookies: cookieStore,
headers: headerStore,
locale: APP_LOCALE,
})
}
Blocked events are not replayed when consent later changes. If the current route, flag, or entry state still qualifies after consent, the SDK can emit a fresh current-state event.
Run these checks before release:
NEXT_PUBLIC_ variables contain only values that can be shipped to the
client.NextAppAutoPageTracker does
not duplicate the first route event.identify(), consent(), and reset() re-resolve only the entries that are configured
for live updates.Copy this:
pnpm implementation:run -- nextjs-sdk_hybrid typecheck
pnpm implementation:run -- nextjs-sdk_hybrid lint
pnpm test:e2e:nextjs-sdk_hybrid
| Symptom | Likely cause | Check |
|---|---|---|
| Entries stay on baseline | Missing selected optimizations, denied consent, unresolved Contentful links, or all-locale CDA | Inspect selectedOptimizations, fetch with one locale, and use enough include depth |
| Two server-side page events appear for one request | A route calls getNextjsServerOptimizationData() more than once without request deduplication |
Wrap the server helper in cache() and reuse the helper result inside one render pass |
| Browser sends a duplicate first page event | initialPageEvent="emit" is used after the server already emitted the same route event |
Use skip only when the selected server owner emitted a page event for the same initial request |
| Browser does not send the first page event | initialPageEvent="skip" is used when the server skipped Optimization |
Use emit when consent is denied, server data is unavailable, or the browser owns first page tracking |
Live entries do not update after identify() or reset() |
liveUpdates is false globally and on the entry |
Set liveUpdates={true} on the entry or on OptimizationRoot |
| Entry views, clicks, or hovers do not emit | Interaction tracking is opted out, consent blocks the event, or no profile is available | Check trackEntryInteraction, entry props, consent state, and states.blockedEventStream |
| Server and browser use different profiles | Cookie domain, path, readability, or consent cleanup differs between runtimes | Use a browser-readable ctfl-opt-aid with consistent path and clear it on withdrawal |
| Server Components fail with browser globals | A Client Component hook or browser-only import crossed into a server module | Keep server imports on /server and browser imports on /client |
| Personalized HTML appears stale | Route or CDN caching is sharing profile-evaluated output | Mark personalized routes dynamic or vary cache keys on the full personalization context |
@contentful/optimization-nextjs for server request data,
server-to-browser state handoff, client takeover, live updates, consent controls, page events,
entry interaction tracking, preview attachment, and Playwright E2E coverage.