Use this guide to add Contentful personalization to a Next.js App Router site you already have. By the end of the quick start, one piece of content will be personalized per visitor in the server-rendered HTML — no flash of default content, no rewrite of how your app fetches or renders.
New to personalization? Here is the whole idea in four points:
That is enough to start. You do not need to understand audiences, traffic allocation, or events yet; this guide introduces each idea at the point you need it.
You will get there in two milestones:
This guide uses @contentful/optimization-nextjs. The /app-router factory gives you app-local
components that do the right thing in both Server and Client Components. Your app keeps ownership of
Contentful fetching, consent policy, identity, routing, caching, and rendering.
If your app uses the Pages Router, use the Next.js Pages Router guide instead.
Most App Router + Contentful sites share one shape: you fetch a page entry and render its
content through your own components. This quick start assumes that shape. In the snippets that
change an existing file, lines prefixed with + are what you add and the rest is a typical app for
context — match the additions to your own file rather than pasting the whole block. If your app is
shaped differently, the change is the same wherever an entry becomes a component; see
Personalizing first paint on the server.
It proves one result: one section renders its personalized variant in the server HTML. It assumes your app may personalize on startup. If personalization must wait for consent, keep this structure and add the Consent, identity, profile, and reset step before you ship.
Install the adapter package.
Copy this:
pnpm add @contentful/optimization-nextjs
Create one module that binds the SDK to your config. You do this once and import from it
everywhere — the resulting components are bound because they carry your config. Use the same
environment-variable convention your app already uses for Contentful. The snippets import it as
@/src/lib/optimization, which assumes the file is at src/lib/optimization.ts and your
tsconfig maps @/* to the project root — adjust the specifier to match your own paths (for
example @/lib/optimization if your alias points at a top-level lib/).
Adapt this to your use case: replace the placeholder values and the import path; the config keys are explained in How the SDK fits your app.
// src/lib/optimization.ts
import { createNextjsAppRouterOptimization } from '@contentful/optimization-nextjs/app-router'
export const APP_LOCALE = 'en-US'
export const { proxy, NextAppAutoPageTracker, OptimizationRoot, OptimizedEntry } =
createNextjsAppRouterOptimization({
clientId: process.env.NEXT_PUBLIC_OPTIMIZATION_CLIENT_ID ?? '',
environment: process.env.NEXT_PUBLIC_OPTIMIZATION_ENVIRONMENT ?? 'main',
locale: APP_LOCALE,
// consent: allowed to personalize and send events for this visitor.
// persistenceConsent: allowed to store a profile-id cookie so results stay consistent.
defaults: { consent: true, persistenceConsent: true },
server: {
enabled: true, // resolve variants on the server, for first paint
consent: { events: true, persistence: true },
},
app: { name: 'my-next-app', version: '1.0.0' },
// contentful: { client }, // opt-in: enables the entryId managed-fetch path
})
Add the request handler so the SDK runs before your pages render. Next.js executes this file on
every matching request; the SDK's proxy reads the visitor's cookies, asks the Experience API
who they are, and stores that identity (an anonymous profile id) in the ctfl-opt-aid cookie
so the same visitor gets consistent variants next time. You are only mounting it — not writing
that logic.
Next.js is version-specific about both the filename and the export name: Next.js 16 loads a
proxy export from proxy.ts; Next.js 15 loads a middleware export from
middleware.ts. Get either wrong and the handler silently never runs — and because
server.enabled is true, OptimizationRoot then throws instead of falling back to baseline.
See Request context and the profile cookie.
Copy this (Next.js 16):
// proxy.ts — at your Next.js app root
export { proxy } from './src/lib/optimization'
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|api).*)'],
}
Copy this (Next.js 15 — same handler, aliased to the middleware export Next.js 15 looks
for):
// middleware.ts — at your Next.js app root
export { proxy as middleware } from './src/lib/optimization'
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|api).*)'],
}
Wrap your layout in OptimizationRoot, and put the page tracker inside it. Keep everything
your layout already renders — header, footer, providers, fonts, styles. You are adding a
wrapper, not replacing the file. Put the root around everything inside <body> (chrome included)
so header, footer, or announcement content can be personalized later too.
Adapt this to your use case: the + lines are the additions; the rest is a typical layout
for context.
// app/layout.tsx
+import { NextAppAutoPageTracker, OptimizationRoot } from '@/src/lib/optimization'
+import { Suspense } from 'react'
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const settings = await getSiteSettings() // your existing code stays
return (
<html lang="en">
<body>
+ <OptimizationRoot>
+ {/* Suspense is required: the tracker reads useSearchParams(), which Next.js
+ only allows inside a Suspense boundary. */}
+ <Suspense>
+ {/* "skip": the server already reported this page view — don't report it twice. */}
+ <NextAppAutoPageTracker initialPageEvent="skip" />
+ </Suspense>
<Header settings={settings} />
{children}
<Footer settings={settings} />
+ </OptimizationRoot>
</body>
</html>
)
}
Wherever your code turns a Contentful entry into a component, wrap it in OptimizedEntry. Many
apps have a single such place — a renderer or registry that maps a content type to a component —
and wrapping it there personalizes every entry it renders; others render an entry directly in a
page. Either way, this is the whole integration: keep your fetch and your components as they are.
Adapt this to your use case: the example is a content-type-to-component renderer. The +
lines are the additions; wrap the entry wherever your own code renders one, keeping your existing
guards.
// e.g. your renderer that maps a content type to a component (yours may be named differently)
+import { OptimizedEntry } from '@/src/lib/optimization'
export function ContentRenderer({ items }) {
return items?.map((entry) => {
const Component = entry ? componentFor(entry.sys.contentType.sys.id) : undefined
if (!entry || !Component) return null // your existing guard stays
- return <Component key={entry.sys.id} entry={entry} />
+ return (
+ <OptimizedEntry key={entry.sys.id} baselineEntry={entry}>
+ {/* Render prop hands back a base `Entry`; cast to your own entry type. */}
+ {(resolved) => <Component entry={resolved as YourEntryType} />}
+ </OptimizedEntry>
+ )
})
}
Check that it works. In Contentful, author a variant on a section that appears on your home page and attach it to an experience — for a first test, target all visitors so you match it automatically. Load the page, View Source (or disable JavaScript), and search the raw HTML for the variant's text. It must be present in the server HTML and stay on screen after the page hydrates. If you see the original content instead, work through Troubleshooting.
You now have personalization working end to end. The rest of this guide is not a re-run of the quick start — it explains what each step did and covers what the quick start deliberately skipped: real, consent-gated startup; your Contentful fetch requirements and the baseline-fallback contract; browser takeover and live updates; interaction tracking; and production hardening. Read straight through to understand the pieces, or jump to the section you need.
The sections below walk the integration in order. First, gather the few things you can only get from outside this guide:
middleware.ts instead of proxy.ts — called out in
step 3).You do not need a setup inventory up front. Everything else — the request handler, the root, entry wrapping, consent, tracking — is introduced by the section that needs it.
This guide uses NEXT_PUBLIC_-prefixed environment variables because Next.js only exposes
variables with that prefix to browser code. Use whatever prefix your app already uses for its
other browser-visible Contentful variables, and keep it consistent.
Integration category: Required for first integration
This section explains the lib/optimization.ts module you created in the quick start — what each
config key does and how to make startup depend on real consent.
The Next.js adapter is a thin layer between three things you already have or control: your Contentful data, Contentful's Experience API, and your React components. You configure it once, and it hands you components that behave correctly on the server and in the browser.
The only import path you need to start is /app-router. It resolves automatically: in a Server
Component the returned components render personalized HTML on the server; in a Client Component the
same imports use the browser runtime. You reach for the other subpaths only later.
| Import path | Use it for |
|---|---|
@contentful/optimization-nextjs/app-router |
The factory returning your bound OptimizationRoot, OptimizedEntry, tracker, and proxy |
@contentful/optimization-nextjs/client |
Browser-only hooks and per-entry live-update controls, inside 'use client' components |
@contentful/optimization-nextjs/server |
Manual server SDK control, for advanced routes only |
@contentful/optimization-nextjs/api-schemas |
Type guards such as isMergeTagEntry and isResolvedContentfulEntry |
Import from a subpath, not the package root — bound components come from /app-router and hooks
from /client; @contentful/optimization-nextjs on its own is not an import path.
The config you pass to createNextjsAppRouterOptimization() breaks down like this:
clientId and environment identify your Optimization project. Read them from browser-safe env
variables.locale is the one locale the SDK uses for Experience and event context. Use the same locale you
pass to Contentful.api overrides the Experience and Insights endpoints. Set these only for a mock, a proxy, or
non-default hosts; omit them otherwise.defaults is the browser SDK's starting state: consent (may personalize and send events) and
persistenceConsent (may store the profile-id cookie).server.enabled: true turns on server-side first paint. server.consent decides, per request,
whether the server may personalize; return false to fall back to baseline.app is your app's name and version, sent as metadata.The quick start used always-on defaults and server.consent to get you a result. For production,
make startup depend on real consent: seed the browser defaults off, and make server.consent a
function that reads your app's recorded choice per request. Everything else stays as it was in
step 2.
CONSENT_COOKIE below is your cookie, not an SDK cookie — you name it, you write it (from your
consent UI or Consent Management Platform (CMP)), and you read it here. The SDK never touches it; it
only calls your server.consent function and personalizes based on what you return. (The one
SDK-managed cookie is ctfl-opt-aid, from the
request handler.) The
Consent, identity, profile, and reset section shows the
Client Component that writes this cookie.
Adapt this to your use case: the same module from step 2, with only defaults and server
changed to read real consent.
// src/lib/optimization.ts
import { createNextjsAppRouterOptimization } from '@contentful/optimization-nextjs/app-router'
export const APP_LOCALE = 'en-US'
const CONSENT_COOKIE = 'app-personalization-consent'
export const { proxy, NextAppAutoPageTracker, OptimizationRoot, OptimizedEntry } =
createNextjsAppRouterOptimization({
clientId: process.env.NEXT_PUBLIC_OPTIMIZATION_CLIENT_ID ?? '',
environment: process.env.NEXT_PUBLIC_OPTIMIZATION_ENVIRONMENT ?? 'main',
locale: APP_LOCALE,
app: { name: 'my-next-app', version: '1.0.0' },
// Changed from step 2: start off, and let per-request consent decide.
defaults: { consent: false, persistenceConsent: false },
server: {
enabled: true,
// Personalize only when your app has recorded consent for this visitor.
consent: ({ cookies }) =>
cookies.get(CONSENT_COOKIE)?.value === 'granted'
? { events: true, persistence: true }
: false,
},
})
Create these bound components exactly once. The bound server path caches its request data within a
single render pass, so the root and every OptimizedEntry in that request share one profile and one
set of decisions. (You store and read that consent cookie from a Client Component; see
Consent, identity, profile, and reset.)
Integration category: Required for first integration
Your app owns the Contentful client. There are two supported ways to get a fetched entry to the SDK's resolution hand-off:
OptimizedEntry
as baselineEntry. Keep your existing client and fetchers; the SDK only needs entries to arrive
in a shape it can resolve.contentful config
key (contentful: { client }), then pass an entryId to a bound server OptimizedEntry instead
of a fetched baselineEntry. The SDK fetches that entry by ID through your client
(sdk.fetchOptimizedEntry(entryId, { query: entryQuery })) and resolves it. The client is still
yours — the SDK uses its getEntry() and getEntries() methods.This guide teaches the manual path. The fetch rules below describe what a resolvable payload looks
like either way: for managed fetching the SDK merges your entryQuery, the SDK locale, and
include: 10 into the Contentful query, so the single-locale and include-depth rules still hold.
When several uncached managed entries share the same normalized query, the SDK uses your client's
getEntries() method. Large getEntries() fetches are split into 100-ID chunks.
withAllLocales or raw Contentful Delivery
API (CDA) locale=* — all-locale payloads use locale-keyed field maps the resolver cannot read,
so entries fall back to baseline.include depth deep enough to resolve the whole tree — the page, its sections, and the
linked variant entries. include: 10 is the common setting and is what most section-composed
sites already use..withoutUnresolvableLinks (common for deeply-linked pages), keep it; it does not
interfere with variant resolution as long as the variant entries are published and within your
include depth.Your existing fetch usually needs no change — most section-composed sites already fetch a page
by slug with a generous include depth and a single locale. A single-locale entry exposes its
optimization fields directly, such as fields.nt_experiences and fields.nt_variants (the nt_
prefix is how personalization links appear on an entry).
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: Common but policy-dependent
This explains the request handler you added in step 3 and how to tune it. Server-side
personalization must know who the visitor is before your page renders — that is the proxy's job.
On each matching request it reads the visitor's cookies, calls the Experience API, and — when
persistence consent allows — writes the returned profile id to the ctfl-opt-aid cookie so the same
visitor stays consistent on later requests.
The two things you control here:
proxy
export from proxy.ts; Next.js 15 loads a middleware export from middleware.ts (alias it:
export { proxy as middleware }). The config object is the same either way. If the filename or
export name is wrong for your version, Next.js never runs the handler, and because
server.enabled is true the bound OptimizationRoot throws on render rather than degrading to
baseline. If you see that error, check the filename and export name against your Next.js version
first.matcher: it must cover every route whose Server Components use server personalization,
and exclude static assets and API routes (as the step-3 matcher does). Narrow it if only some
routes personalize.Consent, locale, and profile policy live in your optimization.ts factory; this file only mounts
the handler.
One cookie constraint matters: do not mark ctfl-opt-aid as HttpOnly — the browser SDK must read
it to keep the same profile after takeover. For how server and browser stay on the same profile, see
Profile synchronization between client and server.
Integration category: Required for first integration
Step 5 showed the wrap. This explains the two things about it that matter everywhere, then covers a
second app shape, and an opt-in managed-fetch variant for apps that configured
contentful: { client }.
In a Server Component, OptimizedEntry resolves the entry against the request's decisions and
renders the variant — or the baseline entry — straight into the HTML. No JavaScript is required for
the visitor to see personalized content. The rule never changes: wherever a Contentful entry
becomes a component, wrap it and render whatever the render prop hands back. Two facts hold
everywhere:
contentful
Entry. If your component expects a narrower type, cast it — resolved as YourSectionType —
which mirrors the reference implementation. This direct cast works for the common cases, including
.withoutUnresolvableLinks-narrowed types. Only if TypeScript rejects a cast for a genuinely
disjoint type do you need resolved as unknown as YourSectionType.OptimizedEntry that shares a baseline entry id
with an OptimizedEntry above it renders nothing (it returns null, with a dev-only warning).
Wrap at one level — the renderer hand-off, or the individual cards, not both.The quick-start example wrapped a content-type-to-component renderer, which covers most section-composed sites. The other common shape is a route that fetches and renders entries directly, without a registry — the wrap and the cast are identical:
Adapt this to your use case:
// app/page.tsx
import { OptimizedEntry } from '@/src/lib/optimization'
export default async function Home() {
const entries = await getHomeEntries() // your existing fetch, unchanged
return entries.map((entry) => (
<OptimizedEntry key={entry.sys.id} baselineEntry={entry}>
{(resolved) => <YourCard entry={resolved as YourEntryType} />}
</OptimizedEntry>
))
}
If you configured the factory with contentful: { client } (the managed path from
Fetching Contentful entries), you can skip the fetch and pass an
entryId instead of a baselineEntry. The bound server OptimizedEntry fetches that entry by ID
through your client and resolves it in the same render — an optional entryQuery merges into the
getEntry() call. Managed entryId fetching in App Router is server-side: the bound server
component holds the Contentful client and fetches during the render, then hands the resolved entry
to the browser. The /client OptimizedEntry and useOptimizedEntry() accept entryId at the
type level, but the browser runtime does not carry the Contentful client, so a browser-only
entryId resolves from the server-provided entry rather than fetching on its own — keep the fetch
on the server. The render prop and cast are unchanged; only the source of the entry differs.
Adapt this to your use case: the managed variant of the example above — no fetch, an entryId
in place of baselineEntry.
// app/page.tsx — requires `contentful: { client }` on the factory
import { OptimizedEntry } from '@/src/lib/optimization'
export default async function Home() {
return (
<OptimizedEntry entryId="your-page-entry-id">
{(resolved) => <YourCard entry={resolved as YourEntryType} />}
</OptimizedEntry>
)
}
If a layout knows the managed IDs before child routes render, pass
prefetchManagedEntries={['your-page-entry-id']} to the bound OptimizationRoot. The App Router
server root fetches those entries and passes them to React Web as prefetchedManagedEntries.
The default App Router path needs no manual resolveOptimizedEntry() call and no custom takeover
boundary. Reach for those only in advanced routes.
Server personalization makes a route dynamic. The bound server components read request
headers(), so any route they render is rendered per request — it can no longer be statically
generated or served from ISR. If the route currently sets export const revalidate = ... or uses
generateStaticParams, those stop applying once you personalize it; remove them, or keep that
route unpersonalized. This is a deliberate trade: personalized HTML is per visitor, so it cannot
also be a single cached page. See
Caching and request deduplication for how to keep caching
raw Contentful data underneath.
Integration category: Required for first integration
Step 4 mounted the root and tracker. Here is what they do and the one decision you have to get
right. OptimizationRoot carries personalization state through your tree and hands the server's
decisions to the browser. NextAppAutoPageTracker reports page events — a signal that a page
was viewed — as the visitor navigates.
Two rules and one decision:
defaults, trackEntryInteraction, onStatesReady, liveUpdates) in the
factory, not as per-render props on the root. The root takes no config of its own.NextAppAutoPageTracker must stay inside Suspense (it reads useSearchParams()), as in
step 4.initialPageEvent="skip" stops the browser reporting a duplicate. Use
"emit" for browser-owned routes that did not use the server path. If startup is consent-gated,
make it conditional — skip when the server owned a consented event, emit otherwise.Adapt this to your use case: attaching route-aware properties to page events.
<Suspense>
<NextAppAutoPageTracker
initialPageEvent="skip" // skip only when the server owns this route's first page event
getPagePayload={({ pathname }) => ({
properties: { routeGroup: pathname.startsWith('/account') ? 'account' : 'public' },
})}
/>
</Suspense>
The tracker deduplicates consecutive route keys, including React Strict Mode's double effects, but
it does not replace your page-event policy. Use skip only when there is a matching server page
event.
Integration category: Required for first integration
This is Milestone 2. First paint is already complete and shippable; add this only when some content must re-personalize after the page loads — for example, when a visitor accepts consent, signs in, or is identified, and entries should update without a reload.
Live updates are opt-in because most content is fixed for the life of a request. You do not add a
provider for this — the bound OptimizationRoot already includes the live-updates provider
internally. You only choose the scope:
Use the bound OptimizationProvider only as an alternative wrapper when an adapter needs provider
control. Do not nest it inside OptimizationRoot: the root already includes the provider, and a
nested provider creates a separate context that can hide server handoffs such as
prefetchedManagedEntries.
liveUpdates: true in the factory config
(createNextjsAppRouterOptimization). The bound root passes it through, so every live-capable
entry re-resolves on state changes.OptimizedEntry from /client in a Client Component and pass
liveUpdates. A per-entry value overrides the app-wide default, so you can opt one entry in
(liveUpdates) or out (liveUpdates={false}) independently. The bound /app-router
OptimizedEntry deliberately omits the per-entry liveUpdates and loadingFallback props so
the same import type-checks in both Server and Client Components — that is why per-entry control
uses the /client import./client hooks such as useOptimizedEntry() only when you need rendering control the
wrapper does not offer.Follow this pattern: the app-wide switch, in the factory from step 2 of the quick start.
createNextjsAppRouterOptimization({
// ...clientId, environment, locale, server, defaults
liveUpdates: true, // every live-capable entry re-resolves on browser state changes
})
Adapt this to your use case: a single client-only entry that re-resolves on profile changes, without turning on the app-wide default.
'use client'
import { OptimizedEntry } from '@contentful/optimization-nextjs/client'
import type { Entry } from 'contentful'
export function LiveEntry({ baselineEntry }: { baselineEntry: Entry }) {
return (
<OptimizedEntry baselineEntry={baselineEntry} liveUpdates>
{(resolved) => <article>{String(resolved.fields.title ?? '')}</article>}
</OptimizedEntry>
)
}
To verify takeover, enable live updates, then trigger identifyUser(), setConsent(), or
resetUser() from a Client Component (see the next sections). Confirm that live entries re-resolve
without a full reload and that entries with liveUpdates={false} stay put until the next render.
Integration category: Common but policy-dependent
Interaction tracking — views, clicks, and hovers on entries — is a browser behavior.
OptimizedEntry renders the metadata the browser SDK needs, and the SDK observes interactions once
consent permits. It is on by default when you use OptimizedEntry, so you rarely configure anything
to get started.
trackEntryInteraction
only to opt out of an interaction type you must not observe.OptimizedEntry props such as clickable, trackViews, trackClicks, and trackHovers
for per-entry control.allowedEventTypes permits them.Follow this pattern: opting out of one detector globally.
createNextjsAppRouterOptimization({
// ...clientId, environment, locale, server, defaults
trackEntryInteraction: { hovers: false }, // opt out only where policy requires it
})
Tracking uses the resolved entry id, not the baseline id. For mechanics, see Interaction tracking in Web SDKs.
Integration category: Common but policy-dependent
Consent, identity, and profile continuity are your application's decisions. The SDK gives you the runtime controls; your app owns the consent record, the privacy notice, the CMP, the identity source, and cookie cleanup.
server.consent and seed accepted consent in
browser defaults.server.consent and call setConsent()
from the Client Component that owns the decision.server.consent can read it next request — the same CMP, account
preference, or cookie.identifyUser() when a visitor becomes known, and resetUser() (plus clearing your own
profile cookies) on sign-out or withdrawal.Adapt this to your use case: a client control panel wired to the SDK actions.
'use client'
import {
useConsentState,
useOptimizationActions,
useProfileState,
} from '@contentful/optimization-nextjs/client'
import { useEffect } from 'react'
const CONSENT_COOKIE = 'app-personalization-consent'
function persistConsent(consented: boolean): void {
// Store where server.consent can read it on the next request.
document.cookie = `${CONSENT_COOKIE}=${consented ? 'granted' : 'denied'}; Path=/; SameSite=Lax`
}
export function PersonalizationControls() {
const { setConsent, identifyUser, resetUser } = useOptimizationActions()
const consent = useConsentState()
const profile = useProfileState()
const isIdentified = Boolean(profile?.traits.identified)
useEffect(() => {
if (typeof consent === 'boolean') persistConsent(consent)
}, [consent])
return (
<div>
<button onClick={() => setConsent(consent !== true)} type="button">
{consent === true ? 'Reject consent' : 'Accept consent'}
</button>
{isIdentified ? (
<button onClick={() => resetUser()} type="button">
Reset profile
</button>
) : (
<button
onClick={() => void identifyUser({ userId: 'user-123', traits: { identified: true } })}
type="button"
>
Identify
</button>
)}
</div>
)
}
With live updates enabled, identifyUser(), setConsent(), and resetUser() can change the
selected variants in the browser and re-render affected entries without a reload. For consent
design, 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 its own events to Contentful; forwarding is application-owned.
onStatesReady so observers attach before child
effects such as route trackers emit events.messageId or a destination-specific key.messageId before subscribing and skip
it.Adapt this to your use case:
const forwardedMessageIds = new Set<string>()
export const { proxy, NextAppAutoPageTracker, OptimizationRoot, OptimizedEntry } =
createNextjsAppRouterOptimization({
// ...clientId, environment, locale, server, defaults
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()
}
},
})
See Forwarding Optimization SDK context to analytics and tag-management tools for request-local server mapping, 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.
getMergeTagValue function passed to the
OptimizedEntry render prop.location.city or location.country.Merge tags live inside Rich Text as embedded entry nodes, so getMergeTagValue takes a merge-tag
entry node — not a plain field. You resolve them while rendering the Rich Text document: for each
embedded entry, guard with isMergeTagEntry (from /api-schemas) and pass the node's target to
getMergeTagValue.
Follow this pattern:
import { OptimizedEntry } from '@/src/lib/optimization'
import { isMergeTagEntry } from '@contentful/optimization-nextjs/api-schemas'
import { documentToReactComponents, type Options } from '@contentful/rich-text-react-renderer'
import { INLINES } from '@contentful/rich-text-types'
import type { Entry } from 'contentful'
export function EntryWithMergeTags({ entry }: { entry: Entry }) {
return (
<OptimizedEntry baselineEntry={entry}>
{(resolved, { getMergeTagValue }) => {
const options: Options = {
renderNode: {
[INLINES.EMBEDDED_ENTRY]: (node) => {
const target = node.data.target
// Only merge-tag nodes resolve to a profile value; render others as usual.
return isMergeTagEntry(target) ? (getMergeTagValue(target) ?? '') : null
},
},
}
return documentToReactComponents(resolved.fields.body as never, options)
}}
</OptimizedEntry>
)
}
Merge tags and entry replacement use different mechanics. Entry replacement swaps the whole entry
for its variant; merge tags read profile-backed values from current SDK state. Use
useMergeTagResolver() from /client only in Client Components that need merge tags outside an
OptimizedEntry render prop.
Integration category: Optional
Use the preview panel where authors or engineers need to inspect variant behavior — including forcing a specific variant to verify a targeted experience. Keep production loading explicit and gate attachment behind an application-owned flag.
OptimizationRoot.NEXT_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 }) {
// The context exposes the SDK instance; it is undefined until the browser SDK is ready.
const { sdk } = useOptimizationContext()
const enabled = process.env.NEXT_PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL === 'true'
useEffect(() => {
if (!enabled || sdk === undefined) return // opt-in, and only after the SDK is ready
void Promise.all([
import('@contentful/optimization-web-preview-panel'),
import('@/src/lib/contentful'), // your Contentful client module
])
.then(async ([{ default: attachOptimizationPreviewPanel }, { client }]) => {
await attachOptimizationPreviewPanel({ contentful: client, nonce })
})
.catch(() => undefined)
}, [sdk, nonce, enabled])
return null
}
A dynamic import only loads the attach function; your app must call
attachOptimizationPreviewPanel(...) with a Contentful client, or with
entries: { audiences, experiences } when you already loaded the preview definitions. entries
takes precedence over contentful.
Integration category: Advanced or production-only
App Router apps can mix strategies. Choose one per route instead of forcing a single model across the whole app.
| Route need | Use this pattern |
|---|---|
| Server is the only content source until the next request | Bound server OptimizedEntry, browser live updates off |
| Server first render plus browser-side reactivity | Bound root handoff plus app-local or /client OptimizedEntry |
| Browser-owned personalization after startup | Render baseline or loading UI on the server and let Client Components own it |
| Highly interactive account, dashboard, or settings surfaces | Client Components with live updates and explicit consent state |
identifyUser(), setConsent(),
resetUser(), live flag state, or manual tracking.OptimizationRoot for takeover subtrees that share browser state.The boundary that matters is ownership: Server Components render through the bound server
OptimizedEntry; Client Components render through the app-local or /client OptimizedEntry when
they need browser-only props.
Integration category: Advanced or production-only
Use manual helpers only when the bound App Router factory cannot express a route's needs.
createNextjsOptimization() and getNextjsServerOptimizationData() from /server for
direct request SDK control, custom server page payloads, or app-owned request deduplication.serverOptimizationState to a /client OptimizationRoot or OptimizationProvider only
in manual server/client setups.getServerTrackingAttributes() only with manual resolveOptimizedEntry() results.Integration category: Advanced or production-only
Personalized server rendering is request-specific. Keep shared caches on raw Contentful payloads, not on profile-evaluated results or personalized HTML, unless your cache key varies on every personalization input.
cache() deduplicate request data within one render pass.Copy this:
// app/layout.tsx or app/page.tsx
export const dynamic = 'force-dynamic'
The exact Next.js cache policy is yours. 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.false from server.consent while consent is unknown or denied.ctfl-opt-aid and your own 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 view.states.blockedEventStream during validation to confirm the SDK blocks what your
policy expects.Adapt this to your use case:
createNextjsAppRouterOptimization({
// ...clientId, environment, locale
allowedEventTypes: [],
defaults: { consent: false, persistenceConsent: false },
server: {
enabled: true,
consent: ({ cookies }) =>
cookies.get('app-personalization-consent')?.value === 'granted'
? { events: true, persistence: true }
: false,
},
})
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:
server.consent, browser consent, anonymous-id persistence, and CMP or account state stay
aligned across first load, navigation, opt-in, opt-out, sign-in, sign-out, and reset.NextAppAutoPageTracker does not
duplicate it.identifyUser(), setConsent(), and resetUser() re-resolve only the entries configured
for live updates.Copy this:
pnpm implementation:run -- nextjs-sdk_app-router typecheck
pnpm implementation:run -- nextjs-sdk_app-router lint
pnpm test:e2e:nextjs-sdk_app-router
| Symptom | Likely cause | Check |
|---|---|---|
| Entries stay on baseline | No variant applies, denied consent, unresolved Contentful links, or all-locale CDA | Author a variant that targets you, check server.consent, fetch one locale with enough include |
| The variant never appears even though it is authored | Your test visitor does not match the experience's audience | Target all visitors for a first test, or force the variant with the preview panel |
<Component entry={resolved} /> shows a type error |
The render prop returns a base Entry, wider than your component's type |
Cast it: resolved as YourSectionType (add as unknown only if TS rejects a genuinely disjoint type) |
| Two server-side page events appear for one request | Multiple bound factories, or a manual helper also calls the server page path | Create bound components once and keep manual getNextjsServerOptimizationData() out of the route |
| Browser sends a duplicate first page event | initialPageEvent="emit" used after the server path already emitted the same route |
Use skip only when the server path owns the same initial request |
| Browser does not send the first page event | initialPageEvent="skip" used on a browser-owned route without a matching server event |
Use emit when the browser owns first page tracking |
Live entries do not update after identifyUser() or resetUser() |
Live updates are off (the default) | Set liveUpdates: true in the factory, or pass liveUpdates to a /client OptimizedEntry |
| Entry views, clicks, or hovers do not emit | Interaction tracking is opted out, consent blocks the event, or no profile is available | Check factory 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 a 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 | Use bound imports in Server Components and /client hooks only in Client Components |
| 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 |
Next.js 15 reports unsupported export * in a client boundary |
A 'use client' module re-exports with export * — in your app, or a package that does |
Use a package build that avoids export * in client entries, and avoid app-authored export * re-exports in Client Components |
getServerSideProps state handoff.