Use this guide when you want to add personalization to a Next.js App Router application where the server is the source of truth for the content shown on each request. The Next.js adapter resolves entries in Server Components before HTML leaves the server, then hands server optimization state to the browser SDK for page events, entry interaction tracking, consent controls, identify, and reset.
If the page must re-resolve entries immediately after a browser-side identify, consent, or reset action, use the hybrid SSR + CSR takeover guide instead.
This quick start proves that one server-resolved Contentful entry renders in the initial HTML as the selected Optimization variant or the baseline fallback. It uses accepted server request consent without profile persistence. Use this path only when your application policy permits an Optimization server page call at first load. If consent depends on a consent management platform (CMP), account preference, or regional rule, use 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.
Copy this:
// lib/optimization-server.ts
import { createNextjsOptimization } from '@contentful/optimization-nextjs/server'
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,
logLevel: 'error',
})
Fetch one Contentful entry in a Server Component, resolve it with request-local Optimization data, and render the resolved entry.
In this snippet, fetchEntryFromContentful() is an app-owned Contentful CDA helper. It must
return one single-locale entry with linked optimization entries and variants included. The
cookieStore and headerStore values come from Next.js cookies() and headers().
<NextjsOptimizationState> is valid when this page renders under SDK context provided by
OptimizationRoot or OptimizationProvider, such as a shared App Router layout. If you have not
added that provider yet, omit the marker until you complete the client provider section.
Adapt this to your use case:
// app/page.tsx
import { APP_LOCALE, optimization } from '@/lib/optimization-server'
import { NextjsOptimizationState } from '@contentful/optimization-nextjs/client'
import { getNextjsServerOptimizationData } from '@contentful/optimization-nextjs/server'
import { cookies, headers } from 'next/headers'
export default async function Home() {
const [cookieStore, headerStore, baselineEntry] = await Promise.all([
cookies(),
headers(),
fetchEntryFromContentful({
entryId: 'homepage-hero',
include: 10,
locale: APP_LOCALE,
}),
])
// Bind request state to the server page call without durable profile persistence.
const { data: optimizationData } = await getNextjsServerOptimizationData(optimization, {
consent: { events: true, persistence: false },
cookies: cookieStore,
headers: headerStore,
locale: APP_LOCALE,
})
// The resolver returns the baseline entry when no selected optimization matches.
const resolvedData = optimization.resolveOptimizedEntry(
baselineEntry,
optimizationData?.selectedOptimizations,
)
const resolvedEntry = resolvedData.entry
return (
<main>
<NextjsOptimizationState data={optimizationData} />
<h1>{String(resolvedEntry.fields.title ?? '')}</h1>
</main>
)
}
Verify the first page load by inspecting the server-rendered HTML response or page source. The
rendered heading must match the selected variant when selectedOptimizations contains a matching
entry decision, or the baseline entry when no matching decision or Optimization data exists.
Use this table as the setup inventory for the full SSR 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 OptimizationRoot props for browser integrations |
| 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 include: 10 and one locale |
| Server Component entry resolution | Required for first integration | Yes | App Router pages and server components |
| Next.js proxy or middleware hook | Common but policy-dependent | No | proxy.ts or middleware.ts |
| Browser SDK context, state handoff, and route tracker | Required for first integration | Conditional | App Router layout and pages |
| Server request consent policy | Common but policy-dependent | Yes | Server calls, browser controls, CMP, or account controls |
| Profile persistence and anonymous ID cookie continuity | Common but policy-dependent | No | Server helper cookies, browser state handoff, ESR persistence, and ctfl-opt-aid |
| Browser identify and reset controls | Common but policy-dependent | No | Client Components using Next.js client hooks |
| Experience API and Insights API endpoint overrides | Advanced or production-only | No | SDK api config for mock, proxy, or regional endpoints |
| Entry interaction tracking | Optional | No | ServerOptimizedEntry, getServerTrackingAttributes(), and trackEntryInteraction |
| Third-party analytics forwarding | Optional | No | OptimizationRoot onStatesReady subscription and app-owned analytics code |
| Production caching and duplicate-event policy | Advanced or production-only | No | Next.js route config, server helper structure, and tracker settings |
| Client-side entry re-resolution, live updates, or preview takeover | Advanced or production-only | No | Use the hybrid pattern instead of this SSR guide |
The application owns Contentful fetching, locale selection, route policy, consent policy, identity policy, and component rendering. The Next.js adapter owns SDK composition: the server entry delegates to the stateless Node SDK, the client entry delegates to the React Web SDK, and the request handler forwards sanitized request context headers for Server Components.
Integration category: Required for first integration
The adapter exposes runtime-specific subpaths. Keep imports on these subpaths so Server Components do not import browser code and Client Components do not import server-only code.
| Import path | Runtime | Responsibility |
|---|---|---|
@contentful/optimization-nextjs/server |
Server Components and server-only modules | SDK creation, request binding, and server entry resolution wrapper |
@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 context capture and SDK-owned request header sanitization |
@contentful/optimization-nextjs/client |
Client Components and browser layout children | React provider, state handoff marker, hooks, App Router page tracker, and entry interaction tracking |
@contentful/optimization-nextjs/api-schemas |
Shared schema helpers | API types plus structural guards such as isMergeTagEntry, isRichTextDocument, and isResolvedContentfulEntry |
@contentful/optimization-nextjs/tracking-attributes |
Shared server-rendering helpers | Lower-level SSR data-ctfl-* tracking attributes |
createNextjsOptimization().clientId, environment, locale, endpoint overrides, app metadata,
and logLevel into that singleton.Integration category: Common but policy-dependent
The request handler captures request context for Server Components. It strips incoming SDK-owned headers, forwards sanitized request context headers including the SDK-owned request URL header, and leaves consent, page calls, and cookie persistence to the server helper or to an explicit request-rendered ESR flow.
createNextjsOptimizationContextHandler() from proxy.ts or middleware.ts for routes
whose Server Components call getNextjsServerOptimizationData().cookies(), headers(), consent, and locale to getNextjsServerOptimizationData().ctfl-opt-aid browser-readable when browser state handoff must continue the same profile.
Do not mark it HttpOnly.persistNextjsAnonymousId() only from custom server code that owns the outgoing response.Adapt this to your use case:
// proxy.ts
import { createNextjsOptimizationContextHandler } from '@contentful/optimization-nextjs/request-handler'
export const proxy = createNextjsOptimizationContextHandler()
For deeper consent mechanics, see Consent management in the Optimization SDK Suite.
Integration category: Required for first integration
The SDK does not fetch Contentful entries. Your application fetches the baseline entries, including
linked optimization entries and variants, then passes the baseline entry and request-local
selectedOptimizations into resolveOptimizedEntry().
nt_experiences, their configuration, and nt_variants; the
reference implementation uses include: 10.getNextjsServerOptimizationData() with the same request cookies, headers, consent, and
locale policy that apply to the rendered response.optimizationData?.selectedOptimizations to optimization.resolveOptimizedEntry().entry. If no optimization data or matching variant is available, the
resolver returns the baseline entry.In this example, cookieStore and headerStore are the values returned by Next.js cookies() and
headers(). fetchEntriesFromContentful() is an app-owned CDA helper that must return
single-locale entries with linked optimization entries and variants included.
Adapt this to your use case:
const appConsent = cookieStore.get('app-personalization-consent')?.value === 'granted'
const [baselineEntries, optimizationData] = await Promise.all([
fetchEntriesFromContentful({ include: 10, locale: APP_LOCALE }),
// Only request Optimization data when app policy permits profile-producing calls.
appConsent
? getNextjsServerOptimizationData(optimization, {
consent: { events: true, persistence: true },
cookies: cookieStore,
headers: headerStore,
locale: APP_LOCALE,
}).then(({ data }) => data)
: undefined,
])
const resolvedEntries = baselineEntries.map((entry) =>
// The resolver returns the baseline entry when no selected optimization matches.
optimization.resolveOptimizedEntry(entry, optimizationData?.selectedOptimizations),
)
Do not pass all-locale CDA payloads from contentful.js withAllLocales or raw CDA locale=*.
Those payloads contain locale-keyed field maps, while the entry resolver expects direct
single-locale fields such as fields.nt_experiences and fields.nt_variants. For the resolver
contract, see
Entry personalization and variant resolution.
Integration category: Required for first integration
SSR content rendering does not need browser JavaScript, but page tracking, entry interactions, consent controls, identify, and reset run in the browser through the Next.js client entry.
OptimizationRoot in the App Router layout.OptimizationRoot. If a Client Component reads environment
variables directly, use NEXT_PUBLIC_ variables. A Server Component layout can also read
server-side config and pass the values as props intentionally.serverOptimizationState={optimizationData} on OptimizationRoot or OptimizationProvider
when that provider or root receives the server data directly. When a shared layout owns the SDK
context and cannot receive page data, render
<NextjsOptimizationState data={optimizationData} /> under that context near the server-rendered
optimized content.NextAppAutoPageTracker in Suspense because it uses App Router navigation hooks.initialPageEvent="skip" when the server already emitted the page event for the initial
route. Leave route changes enabled so client-side navigation continues to emit page events.Adapt this to your use case:
<OptimizationRoot
clientId={process.env.NEXT_PUBLIC_CONTENTFUL_OPTIMIZATION_CLIENT_ID ?? ''}
environment={process.env.NEXT_PUBLIC_CONTENTFUL_OPTIMIZATION_ENVIRONMENT ?? 'main'}
// Accepted browser startup enables route events.
defaults={{ consent: true }}
locale={APP_LOCALE}
logLevel="error"
>
<Suspense>
{/* Skip only when the server already emitted the first page event. */}
<NextAppAutoPageTracker initialPageEvent="skip" />
</Suspense>
{children}
</OptimizationRoot>
For policy-dependent consent, derive the initial tracker behavior from the same source that the server used:
Adapt this to your use case:
const appConsent = cookieStore.get('app-personalization-consent')?.value === 'granted'
<OptimizationRoot
clientId={optimizationConfig.clientId}
environment={optimizationConfig.environment}
// Seed browser consent only from the same policy source used by the server.
defaults={appConsent ? { consent: true } : undefined}
locale={APP_LOCALE}
>
<Suspense>
{/* Emit from the browser when the server skipped Optimization for this request. */}
<NextAppAutoPageTracker initialPageEvent={appConsent ? 'skip' : 'emit'} />
</Suspense>
{children}
</OptimizationRoot>
Integration category: Common but policy-dependent
Client actions can update SDK consent and the Optimization profile, but they do not replace content already rendered by the server. The next server request, route navigation, or browser refresh reads the updated profile state and resolves entries again.
consent(true), consent(false), or object-form consent from a Client Component after the
user or application policy changes.identify() only when your identity policy permits profile mutation.reset() and clear application-owned cookies when withdrawal must end active profile
continuity.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'
document.cookie = `${APP_PERSONALIZATION_CONSENT_COOKIE}=${value}; Path=/; SameSite=Lax`
}
export function OptimizationControls() {
const { consent: setConsent, identify, reset } = useOptimizationActions()
const consent = useConsentState()
const profile = useProfileState()
useEffect(() => {
// Keep the next server request aligned with browser SDK consent.
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={() => reset()} type="button">
Reset profile
</button>
) : (
<button
onClick={() => void identify({ userId: 'user-123', traits: { identified: true } })}
type="button"
>
Identify
</button>
)}
</div>
)
}
Integration category: Optional
The browser client can automatically observe server-rendered entry wrappers when the markup contains
the data-ctfl-* tracking attributes. Use ServerOptimizedEntry to render those attributes from
the same baseline entry and resolved data used for SSR content.
ServerOptimizedEntry.ResolvedData returned by
resolveOptimizedEntry().getServerTrackingAttributes() from @contentful/optimization-nextjs/tracking-attributes
when an existing server-rendered element or design-system component must own the wrapper markup.trackEntryInteraction on OptimizationRoot only to opt out of interaction types the app
must not observe.clickable, trackViews, trackClicks, trackHovers, and duration interval props only
when an entry needs per-element tracking behavior.Adapt this to your use case:
<ServerOptimizedEntry
as="article"
// Use the same baseline entry and resolved data that produced the SSR content.
baselineEntry={baselineEntry}
clickable
hoverDurationUpdateIntervalMs={1000}
resolvedData={resolvedData}
>
<h2>{resolvedData.entry.fields.title}</h2>
</ServerOptimizedEntry>
Use the lower-level helper when the wrapper element comes from your component library. The component
must forward the data-ctfl-* attributes to the DOM element that the browser SDK observes:
Adapt this to your use case:
import { getServerTrackingAttributes } from '@contentful/optimization-nextjs/tracking-attributes'
const trackingAttributes = getServerTrackingAttributes(baselineEntry, resolvedData, {
clickable: true,
})
return (
<ArticleCard {...trackingAttributes}>
<h2>{resolvedData.entry.fields.title}</h2>
</ArticleCard>
)
Automatic interaction tracking is still gated by browser-side SDK consent. If consent is denied or unset and the interaction type is not allow-listed, automatic detectors may stay stopped and no per-element blocked payload may appear. Blocked-event diagnostics are exposed through browser SDK state only for SDK calls that reach Core.
Browser-side Insights interactions also need an active browser profile signal. When
initialPageEvent="skip" prevents the browser from emitting the first-route page(), rely on one
of these paths before depending on non-sticky entry views, clicks, hovers, or flag views: server
optimization state handed to the browser, a persisted browser profile loaded under persistence
consent, or a later browser Experience call such as page(), identify(), track(), or sticky
trackView(). A readable ctfl-opt-aid cookie alone does not populate the current browser profile
signal. Sticky trackView() can bootstrap through Experience before sending its paired Insights
event.
Integration category: Optional
Forwarding Optimization context to a tag manager, customer-data platform, or analytics destination is application-owned. The Optimization SDK still sends its own events to Contentful; forwarding copies only the fields your governance policy allows.
OptimizationRoot onStatesReady so the subscription exists before child
route trackers emit events.states.eventStream.messageId so current snapshots, subscriber remounts,
retries, or duplicate browser deliveries do not resend the same SDK event record.messageId
before subscribing and skip that event.viewId, componentId, experienceId, and variantIndex.states.blockedEventStream and destination debuggers to verify consent behavior.In this example, analytics is your destination client, canForwardSdkEvent() enforces your
governance and consent allow-list, shouldForwardContentfulEvent() applies destination-specific
semantic dedupe, and pickContentfulEventProperties() maps only the approved SDK fields for that
destination.
Adapt this to your use case:
const forwardedMessageIds = new Set<string>()
<OptimizationRoot
clientId={process.env.NEXT_PUBLIC_CONTENTFUL_OPTIMIZATION_CLIENT_ID ?? ''}
environment={process.env.NEXT_PUBLIC_CONTENTFUL_OPTIMIZATION_ENVIRONMENT ?? 'main'}
// Attach subscriptions before child route trackers and interaction observers emit.
onStatesReady={(states) => {
const initialMessageId = states.eventStream.current?.messageId
const subscription = states.eventStream.subscribe((event) => {
if (!event) return
// Message IDs prevent duplicate forwarding to app-owned destinations.
if (forwardedMessageIds.has(event.messageId)) return
if (event.messageId === initialMessageId) {
forwardedMessageIds.add(event.messageId)
return
}
if (!canForwardSdkEvent(event)) return
forwardedMessageIds.add(event.messageId)
if (!shouldForwardContentfulEvent(event)) return
analytics.track(`Contentful ${event.type}`, pickContentfulEventProperties(event))
})
return () => subscription.unsubscribe()
}}
>
{children}
</OptimizationRoot>
Use Forwarding Optimization SDK context to analytics and tag-management tools for server mapping, browser subscription helpers, vendor examples, consent, identity, dedupe, and governance guidance.
Integration category: Advanced or production-only
Most apps use the same appLocale for Contentful CDA requests, the Next.js server helper, and the
browser provider. The SDK does not choose Contentful locales or modify CDA requests for you.
getNextjsServerOptimizationData({ locale }) when Experience API
responses and event context must match the rendered content language.OptimizationRoot so browser route events use the same locale.experienceOptions and insightsOptions only for lower-level request overrides such as
preflight, custom beacon handling, IP overrides, or endpoint-specific options.For the broader locale model, see Locale handling in the Optimization SDK Suite.
Integration category: Advanced or production-only
Personalized SSR creates request-local data. Cache raw Contentful delivery payloads in the application layer, but do not cache profile-evaluated SDK results across visitors.
getNextjsServerOptimizationData() and request-bound page() results as non-cacheable
across requests because they perform side effects and return profile-specific data.initialPageEvent="skip" for the first browser route when a server page call already emitted
the initial page event.Follow this pattern:
// app/personalized-page/page.tsx
// Personalized routes must not reuse profile-evaluated HTML across visitors.
export const dynamic = 'force-dynamic'
Integration category: Advanced or production-only
Use ESR when a route handler, edge function, or other request-rendered surface owns the incoming
Request and outgoing Response. Do not use ESR for the default App Router Server Component path
when cookies(), headers(), getNextjsServerOptimizationData(), and NextjsOptimizationState
fit the route.
getNextjsEsrOptimizationData() from @contentful/optimization-nextjs/esr.Request or NextRequest, request consent, locale, and optional page payload.data.persist(response) after creating the response when persistence consent permits profile
continuity.Adapt this to your use case:
import { getNextjsEsrOptimizationData } from '@contentful/optimization-nextjs/esr'
export async function GET(request: Request) {
const esr = await getNextjsEsrOptimizationData(optimization, {
consent: { events: true, persistence: true },
locale: APP_LOCALE,
request,
})
const response = new Response(renderHtml(esr.data), {
headers: { 'content-type': 'text/html; charset=utf-8' },
})
esr.persist(response)
return response
}
Integration category: Advanced or production-only
The SSR pattern keeps ServerOptimizedEntry content server-authoritative. Content resolved and
rendered on the server stays fixed after browser startup until the next server request. Client-side
SDK actions such as identify(), consent(), reset(), live updates, or preview-panel changes do
not rewrite that server-rendered markup in place.
SSR routes can still include browser-owned islands when the page needs a localized reactive area.
Render those islands with the client entry, such as OptimizedEntry, useOptimizedEntry(), or
LiveUpdatesProvider, and treat that island as browser-owned after hydration. This is useful for
secondary widgets, preview/editor tools, or content blocks where liveUpdates and preview-panel
variant changes are acceptable without changing the route's primary server-first content model.
Use the hybrid guide when browser takeover is the route's main content model: the same primary entry must render server-personalized HTML for first paint and then continue re-resolving in the browser after identify, consent, reset, live-update, or preview-panel changes. Keep this SSR guide for routes where first-paint stability, SEO-friendly HTML, and server-authoritative primary content matter more than immediate browser-side changes to the main content.
Before releasing a Next.js SSR integration, verify these checks:
blockedEventStream and onEventBlocked for direct SDK calls
that reach Core and are blocked by consent or allowedEventTypes.initialPageEvent="skip" when
the server already emitted the page event, the app does not mount multiple page trackers for the
same route tree, exact analytics records are deduplicated by messageId, and sticky-view
exposures use semantic dedupe when the destination wants one exposure.ctfl-opt-aid cookie is written only when persistence consent
permits it, is cleared on withdrawal when required, and is forwarded to third parties only through
approved app-owned mapping.| Symptom | Likely cause | Check |
|---|---|---|
| The page always renders baseline content | No optimization data, missing consent, all-locale CDA payloads, or unresolved optimization links | Confirm the server helper returned selectedOptimizations, fetch with one locale, and use include: 10 |
| The browser emits a duplicate first page event | The initial page tracker emitted after a server page call | Set initialPageEvent="skip" when the server already emitted the initial page event |
| Entry view, click, or hover events do not appear | Missing data-ctfl-* attributes, opted-out trackEntryInteraction, or denied browser consent |
Render ServerOptimizedEntry, inspect opt-out settings, and inspect blocked-event state |
| A Server Component fails with browser globals or hook errors | A server file imported the Next.js client entry or React SDK hooks | Move hook usage to a Client Component with 'use client' and keep server files on the server entry |
| Identify works but content does not change immediately | Expected SSR behavior | Navigate or refresh so the next server request resolves entries with the updated profile |
| Anonymous profile continuity is lost | The anonymous ID cookie is absent, HttpOnly, denied by persistence consent, or cleared on reset |
Inspect ctfl-opt-aid, server or ESR persistence, browser consent state, and withdrawal logic |
implementations/nextjs-sdk_ssr - Working
Next.js App Router SSR application using @contentful/optimization-nextjs/server,
@contentful/optimization-nextjs/request-handler, and @contentful/optimization-nextjs/client.
Use it to compare proxy request context forwarding, server entry resolution,
ServerOptimizedEntry, App Router layout tracking, and browser controls.