Integrate the Optimization SDK in a Next.js app (SSR-primary)
Table of contents
- Overview
- Scope and capabilities
- The integration flow
- 1. Install the packages
- 2. Create the Node SDK singleton
- 3. Set up the anonymous ID cookie in middleware
- 4. Resolve entries in a Server Component
- 5. Load the React Web SDK on the client only
- 6. Mount the client provider in your layout
- 7. Handle consent and identify from client components
- Understand when personalization updates
- Caching considerations
- The server and client SDK boundary
- Reference implementations to compare against
Overview
This guide helps you add personalization to a Next.js App Router application where the server is the single source of truth for which variant to show. The Node SDK resolves entries server-side before HTML leaves the server. The React Web SDK hydrates on the client for analytics tracking and interactive controls such as consent and identify.
If you need instant client-side reactivity after identify or consent, for example, showing a personalized welcome message without a page refresh, see the Hybrid SSR + CSR takeover guide instead.
Scope and capabilities
The SSR-primary pattern uses two packages together:
@contentful/optimization-node— stateless, server-side. Runs in Server Components, middleware, and Edge Runtime. Resolves which entry variant to render before the HTML response leaves the server.@contentful/optimization-react-web— stateful, browser-side. Initializes after hydration and handles page view tracking, entry interaction tracking, consent, and identify. It never resolves entry variants in this pattern.
What this setup gives you:
- No flicker. Personalized content is in the initial HTML. No loading states or client-side variant swaps.
- SEO-friendly rendering. The search engine sees the resolved personalized content.
- Minimal client JavaScript. Content rendering requires no client-side JavaScript. Only tracking and interactive controls require hydration.
- No Next.js-specific SDK wrapper. The Node SDK works in Server Components and Edge Runtime middleware out of the box. The React Web SDK works in Client Components. No additional framework glue is required.
What it does not give you:
- Instant content updates after client-side actions. When the user accepts consent, identifies, or resets their profile, the displayed content does not change until the next server request. Client actions update the Optimization profile server-side, but the rendered HTML is a snapshot of the profile state at request time.
The integration flow
| Concern | Where it runs | SDK used |
|---|---|---|
| Anonymous ID cookie lifecycle | Middleware (Edge Runtime) | Node SDK |
| Profile resolution and variant pick | Server Component | Node SDK (sdk.page()) |
| Entry variant resolution | Server Component | Node SDK (sdk.resolveOptimizedEntry()) |
| HTML rendering | Server Component | None (plain React) |
| Page view tracking | Client (after hydration) | React Web SDK (NextAppAutoPageTracker) |
| Entry interaction tracking | Client (after hydration) | React Web SDK (trackEntryInteraction) |
| Consent management | Client (after hydration) | React Web SDK (sdk.consent()) |
| User identification | Client (after hydration) | React Web SDK (sdk.identify()) |
In practice, the integration follows this sequence:
- Create one Node SDK instance shared across Server Components and middleware.
- Use Next.js middleware to maintain the anonymous ID cookie on every request.
- In Server Components, read the cookie, call
sdk.page(), and resolve each entry variant. - Load the React Web SDK with
next/dynamicandssr: falseso it only runs in the browser. - Mount the React Web SDK provider and page tracker in a
'use client'wrapper in your layout. - Use Client Components for consent, identify, and any interactive SDK controls.
The SSR-primary reference implementation in our GitHub repository shows that pattern in a working application:
1. Install the packages
pnpm add @contentful/optimization-node @contentful/optimization-react-web
2. Create the Node SDK singleton
Create the SDK once at module level. It is stateless and safe to share across all requests.
// lib/optimization-server.ts
import ContentfulOptimization from '@contentful/optimization-node'
const sdk = new ContentfulOptimization({
clientId: process.env.CONTENTFUL_OPTIMIZATION_CLIENT_ID ?? '',
environment: process.env.CONTENTFUL_OPTIMIZATION_ENVIRONMENT ?? 'main',
api: {
experienceBaseUrl: process.env.CONTENTFUL_EXPERIENCE_API_BASE_URL,
insightsBaseUrl: process.env.CONTENTFUL_INSIGHTS_API_BASE_URL,
},
app: {
name: 'my-next-app',
version: '1.0.0',
},
logLevel: 'error',
})
export { sdk }
Do not create a new instance per request. The Node SDK is designed to be a process-level singleton. Pass request-scoped context (locale, user agent, profile, page URL) as arguments to each method call.
3. Set up the anonymous ID cookie in middleware
Next.js middleware runs on the Edge Runtime before every request reaches a Server Component. Use it to ensure the anonymous ID cookie exists and is populated before the Server Component tries to read it.
// middleware.ts
import { sdk } from '@/lib/optimization-server'
import { ANONYMOUS_ID_COOKIE } from '@contentful/optimization-node/constants'
import { type NextRequest, NextResponse } from 'next/server'
export async function middleware(request: NextRequest): Promise<NextResponse> {
const anonymousId = request.cookies.get(ANONYMOUS_ID_COOKIE)?.value
const profile = anonymousId ? { id: anonymousId } : undefined
const url = new URL(request.url)
const data = await sdk.page({
locale: request.headers.get('accept-language')?.split(',')[0] ?? 'en-US',
userAgent: request.headers.get('user-agent') ?? 'next-js-server',
page: {
path: url.pathname,
query: Object.fromEntries(url.searchParams),
referrer: request.headers.get('referer') ?? '',
search: url.search,
url: request.url,
},
profile,
})
const response = NextResponse.next()
if (data.profile.id) {
response.cookies.set(ANONYMOUS_ID_COOKIE, data.profile.id, {
path: '/',
sameSite: 'lax',
})
}
return response
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|api).*)'],
}
The ANONYMOUS_ID_COOKIE constant is the shared cookie name used by both the Node SDK and the React Web SDK. Using the same name means that after hydration, the Web SDK reads the same anonymous ID from document.cookie and continues the same profile journey the server started.
HttpOnly. The Web SDK reads it from the browser.
The middleware calls sdk.page() in addition to the Server Component because the middleware call ensures the cookie is set and populated in the Set-Cookie header of the response before the Server Component runs. Without this, the first request to a page that has no existing cookie would see an empty cookie store in the Server Component.
4. Resolve entries in a Server Component
Inside a Server Component, read the cookie, call sdk.page() in parallel with your Contentful fetch, then resolve each entry:
// app/page.tsx
import { sdk } from '@/lib/optimization-server'
import { ANONYMOUS_ID_COOKIE } from '@contentful/optimization-node/constants'
import { cookies, headers } from 'next/headers'
export default async function Home() {
const cookieStore = await cookies()
const headerStore = await headers()
const anonymousId = cookieStore.get(ANONYMOUS_ID_COOKIE)?.value
const profile = anonymousId ? { id: anonymousId } : undefined
const [baselineEntries, optimizationData] = await Promise.all([
fetchEntriesFromContentful(),
sdk.page({
locale: headerStore.get('accept-language')?.split(',')[0] ?? 'en-US',
userAgent: headerStore.get('user-agent') ?? 'next-js-server',
profile,
}),
])
const resolvedEntries = baselineEntries.map((entry) => {
const { entry: resolved } = sdk.resolveOptimizedEntry(
entry,
optimizationData.selectedOptimizations,
)
return resolved
})
return (
<main>
{resolvedEntries.map((entry) => (
<EntryCard key={entry.sys.id} entry={entry} />
))}
</main>
)
}
Fetch Contentful entries with include: 10 so that linked optimization data (such as nt_experiences) is included in the response. The Node SDK needs those nested fields to evaluate variants.
resolveOptimizedEntry is synchronous. It picks the correct variant from the resolved entry based on selectedOptimizations returned by sdk.page(). If no optimization applies, it returns the baseline entry unchanged.
Add data attributes for client-side tracking
After hydration, the React Web SDK's trackEntryInteraction option uses a MutationObserver to find elements with specific data-ctfl-* attributes and register interaction trackers (views, clicks, hovers) against them. For automatic tracking to work on server-rendered entries, add these attributes to the wrapper element:
function ServerRenderedEntry({
baselineEntry,
resolvedEntry,
}: {
baselineEntry: ContentEntry
resolvedEntry: ContentEntry
}) {
return (
<div data-ctfl-entry-id={resolvedEntry.sys.id} data-ctfl-baseline-id={baselineEntry.sys.id}>
<p>{resolvedEntry.fields.title}</p>
</div>
)
}
data-ctfl-entry-id is the resolved (possibly variant) entry ID. data-ctfl-baseline-id is the original baseline entry ID. Both are required for the client SDK to associate interaction events with the correct optimization context.
5. Load the React Web SDK on the client only
The React Web SDK depends on browser APIs (localStorage, document.cookie, IntersectionObserver). These APIs are not available during server rendering in Next.js. Importing the package in a Server Component causes a runtime error.
Use next/dynamic with ssr: false to prevent the SDK from loading during server rendering:
// components/ClientProviderWrapper.tsx
'use client'
import dynamic from 'next/dynamic'
import { Suspense, type ReactNode } from 'react'
const OptimizationRoot = dynamic(
() =>
import('@contentful/optimization-react-web').then((mod) => ({
default: mod.OptimizationRoot,
})),
{ ssr: false },
)
The 'use client' directive is required. next/dynamic is a Client Component feature, and the import of @contentful/optimization-react-web must not reach Server Component module graph resolution.
Why ssr: false is required
Next.js tries to pre-render Client Components on the server (a technique sometimes called server-side rendering of client components). Without ssr: false, Next.js attempts to render OptimizationRoot on the server and fails because the package accesses browser globals at import time.
ssr: false tells Next.js to skip server rendering for OptimizationRoot entirely. The SDK initializes only after JavaScript loads in the browser. Provider-owned initialization still happens outside React render and gates children until readiness; in normal browser rendering it uses layout-effect scheduling so ready children can mount before first visible client paint.
Add the page tracker
Mount NextAppAutoPageTracker inside OptimizationRoot to emit page() events automatically whenever the App Router pathname changes:
const NextAppAutoPageTracker = dynamic(
() =>
import('@contentful/optimization-react-web/router/next-app').then((mod) => ({
default: mod.NextAppAutoPageTracker,
})),
{ ssr: false },
)
Wrap the tracker in <Suspense> to prevent it from blocking the initial render. If local diagnostics need SDK state subscriptions that are attached as soon as SDK state exists and before the first automatically emitted page() event, use OptimizationRoot's onStatesReady prop to subscribe to states.eventStream or states.blockedEventStream.
6. Mount the client provider in your layout
Assemble the client wrapper component and use it in app/layout.tsx:
// components/ClientProviderWrapper.tsx
'use client'
import dynamic from 'next/dynamic'
import { Suspense, type ReactNode } from 'react'
const OptimizationRoot = dynamic(
() =>
import('@contentful/optimization-react-web').then((mod) => ({
default: mod.OptimizationRoot,
})),
{ ssr: false },
)
const NextAppAutoPageTracker = dynamic(
() =>
import('@contentful/optimization-react-web/router/next-app').then((mod) => ({
default: mod.NextAppAutoPageTracker,
})),
{ ssr: false },
)
export function ClientProviderWrapper({ children }: { children: ReactNode }) {
return (
<OptimizationRoot
clientId={process.env.NEXT_PUBLIC_OPTIMIZATION_CLIENT_ID ?? ''}
environment={process.env.NEXT_PUBLIC_OPTIMIZATION_ENVIRONMENT ?? 'main'}
trackEntryInteraction={{ views: true, clicks: true, hovers: true }}
logLevel="error"
>
<Suspense>
<NextAppAutoPageTracker />
</Suspense>
{children}
</OptimizationRoot>
)
}
// app/layout.tsx
import { ClientProviderWrapper } from '@/components/ClientProviderWrapper'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<ClientProviderWrapper>{children}</ClientProviderWrapper>
</body>
</html>
)
}
layout.tsx itself is a Server Component. ClientProviderWrapper is a Client Component, which is correct — React's composition model allows a Server Component to render a Client Component as a child.
Environment variables exposed to client code in Next.js must use the NEXT_PUBLIC_ prefix. The Node SDK on the server reads variables without that prefix. Keep them separate in your .env file:
# Used by the Node SDK (server only)
CONTENTFUL_OPTIMIZATION_CLIENT_ID="your-client-id"
CONTENTFUL_OPTIMIZATION_ENVIRONMENT="main"
# Used by the React Web SDK (exposed to the browser)
NEXT_PUBLIC_OPTIMIZATION_CLIENT_ID="your-client-id"
NEXT_PUBLIC_OPTIMIZATION_ENVIRONMENT="main"
7. Handle consent and identify from client components
Consent and identify are client-side concerns. Create a Client Component that subscribes to the SDK state and exposes the controls:
// components/InteractiveControls.tsx
'use client'
import { useOptimizationContext } from '@contentful/optimization-react-web'
import { useEffect, useState } from 'react'
export function InteractiveControls() {
const { sdk, isReady } = useOptimizationContext()
const [consent, setConsent] = useState<boolean | undefined>(undefined)
useEffect(() => {
if (!sdk || !isReady) return
const sub = sdk.states.consent.subscribe(setConsent)
return () => sub.unsubscribe()
}, [sdk, isReady])
if (!sdk || !isReady) return null
return (
<div>
<button onClick={() => sdk.consent(consent !== true)}>
{consent === true ? 'Reject consent' : 'Accept consent'}
</button>
<button onClick={() => sdk.identify({ userId: 'user-123' })}>Identify</button>
<button onClick={() => sdk.reset()}>Reset profile</button>
</div>
)
}
InteractiveControls can be mounted inside a Server Component page — React allows Client Components to be children of Server Components.
Client actions update the Optimization profile via the Experience API, but they do not re-render the server-resolved content on the current page. The updated profile is reflected on the next server request (navigation or browser refresh).
Understand when personalization updates
| User action | Effect on displayed content | When personalization updates |
|---|---|---|
| First page load (anonymous) | Baseline or variant per profile | Immediate (server-resolved) |
| Accept or reject consent | No change to content | Next server request |
Identify (sdk.identify()) |
No change to content | Next server request |
| Navigate to another page | New server-resolved content | Immediate (new SSR) |
| Browser refresh | Server re-resolves with updated profile | Immediate (new SSR) |
The key insight is that client actions update the profile server-side through the Experience API, but the rendered HTML is a snapshot of the profile state at the time of the server request. The next request reflects the updated profile.
This behavior is intentional: the server is the sole source of truth for what content to show. The client never re-resolves entries in this pattern.
Caching considerations
The personalization loop makes certain pieces of your response non-cacheable at the CDN or reverse proxy layer:
- The result of
sdk.page()must not be cached and reused across requests. It performs a server-side effect and returns profile state for the current visitor. - Resolved entry HTML (the output of
resolveOptimizedEntry()) is only cache-safe if you vary the cache on both the baseline entry version and a fingerprint ofselectedOptimizations. In practice, most teams treat server-rendered personalized responses as uncacheable. - Raw Contentful delivery responses (baseline entries before resolution) are broadly cache-safe and can be cached by entry ID, locale, include depth, and environment.
If your deployment uses Next.js full-route caching or generateStaticParams, personalized routes
must be excluded from those caches or must vary on the full profile state.
The server and client SDK boundary
Keep this boundary strict throughout the application:
- Server Components import only from
@contentful/optimization-node. - Client Components (
'use client') import only from@contentful/optimization-react-web.
Mixing them causes runtime errors or bundling failures. The most common mistake is importing a React Web SDK hook at the top of a file that is later resolved as a Server Component. If you see an error about browser globals in a server context, trace the import chain back to a file missing the 'use client' directive.
A practical rule: any file that imports from @contentful/optimization-react-web must begin with 'use client', or it must only be imported by files that do.
Reference implementations to compare against
implementations/react-web-sdk+node-sdk_nextjs-ssr: working Next.js App Router application using the SSR-primary pattern. The server resolves all entries, the client handles tracking and interactive controls only.middleware.ts: Edge Runtime cookie lifecyclelib/optimization-server.ts: Node SDK singletonapp/page.tsx: Server Component fetching entries and resolving variantscomponents/ClientProviderWrapper.tsx: dynamic React Web SDK providercomponents/InteractiveControls.tsx: Client Component for consent, identify, and reset