Integrate the Optimization SDK in a Next.js app (hybrid SSR + CSR takeover)
Table of contents
- Overview
- Scope and capabilities
- The integration flow
- 1. Install the packages
- 2. Create the Node SDK singleton and a cached page helper
- 3. Set up the anonymous ID cookie in middleware
- 4. Resolve entries on the server for first paint
- 5. Seed the React Web SDK with server-resolved state
- 6. Re-resolve entries client-side after hydration
- 7. Handle consent and identify from client components
- Understand when personalization updates
- Known gap: redundant Experience API call on hydration
- Choosing between SSR-primary and hybrid patterns per route
- The server and client SDK boundary
- Reference implementations to compare against
Overview
This guide explains how you can personalize a Next.js App Router application where the first page load is server-resolved (no flicker, SEO-friendly) and subsequent client-side interactions such as identify, consent, and profile reset re-resolve entries immediately without a page refresh.
If instant post-identify reactivity is not required and you prefer the simpler mental model where the server is always the sole source of truth, use the SSR-primary guide instead.
Scope and capabilities
The hybrid SSR + CSR takeover pattern uses the same two packages as the SSR-primary pattern, but gives the React Web SDK a more active role after hydration:
@contentful/optimization-node— stateless, server-side. Resolves entry variants before the HTML response leaves the server. Also used to seed initial optimization state into the client provider.@contentful/optimization-react-web— stateful, browser-side. After hydration, takes over entry resolution so that profile changes (consent, identify, reset) immediately re-resolve which variant to render without a server roundtrip.
What this setup provides:
- No flicker on first paint. The initial HTML contains server-resolved personalized content.
- Instant reactivity after hydration. Calling
sdk.identify(),sdk.consent(), orsdk.reset()immediately updates the resolved entries on screen. - SPA-style navigation. After the first server request, subsequent
<Link>navigations resolve variants client-side. - Mixed per-route strategy. High-SEO pages (homepage, landing pages) can remain Server Components with Node SDK resolution. Interactive pages (dashboard, account settings) can be Client Components with React Web SDK resolution.
What it does not provide (compared to the SSR-primary pattern):
- Simplified mental model. There are now two resolution paths — the server path for first paint and the client path for subsequent interactions. Bugs that appear only after hydration can be harder to reproduce.
- A fully server-authoritative content model. After hydration, the client SDK decides what to render based on its own state. The server and client can briefly disagree if the client SDK has not yet finished its initial API call.
The integration flow
| Concern | First paint (server) | After hydration (client) |
|---|---|---|
| Profile resolution | Middleware + Server Component (Node SDK) | React Web SDK (automatic on init) |
| Entry resolution | sdk.resolveOptimizedEntry() in Server Component |
resolveEntry() via useEntryResolver() hook |
| Entry fetching | Server-side from Contentful | Client-side from Contentful (for new routes) |
| Page tracking | N/A | NextAppAutoPageTracker fires on route change |
| Interaction tracking | N/A (data attributes rendered server-side) | trackEntryInteraction observes elements |
| Consent / identify / reset | N/A | React Web SDK — triggers immediate re-resolution |
In practice, the integration follows this sequence:
- 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 the server layout, call
sdk.page()once and pass the result asdefaultsinto the client provider. - In Server Component pages, use
sdk.resolveOptimizedEntry()for first-paint content. - In Client Component pages or components, use
resolveEntry()fromuseEntryResolver()for reactive content. - Load the React Web SDK with
next/dynamicandssr: false. - Use Client Components for consent, identify, and any interactive SDK controls.
The hybrid 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 and a cached page helper
Create the SDK once at module level, then wrap sdk.page() in React's cache() function so that multiple Server Components on the same request share a single API call:
// lib/optimization-server.ts
import ContentfulOptimization from '@contentful/optimization-node'
import { ANONYMOUS_ID_COOKIE } from '@contentful/optimization-node/constants'
import { cookies, headers } from 'next/headers'
import { cache } from 'react'
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',
})
const getOptimizationData = cache(async () => {
const cookieStore = await cookies()
const headerStore = await headers()
const anonymousId = cookieStore.get(ANONYMOUS_ID_COOKIE)?.value
const profile = anonymousId ? { id: anonymousId } : undefined
return sdk.page({
locale: headerStore.get('accept-language')?.split(',')[0] ?? 'en-US',
userAgent: headerStore.get('user-agent') ?? 'next-js-server',
profile,
})
})
export { sdk, getOptimizationData }
cache() is a React Server Component primitive that deduplicates calls within a single render pass. Both the layout and the page can call getOptimizationData() and only one HTTP request to the Experience API is made per server request. This is more important in the hybrid pattern than in the SSR-primary pattern because the layout also needs the data to seed the client provider.
3. Set up the anonymous ID cookie in middleware
This step is identical to the SSR-primary pattern. Middleware runs before every request and ensures the anonymous ID cookie is set before any Server Component reads 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 shared between the Node SDK and the React Web SDK. After hydration, the Web SDK reads the same cookie from document.cookie and continues the same anonymous profile journey. Do not mark this cookie as HttpOnly.
4. Resolve entries on the server for first paint
In Server Component pages, resolve entries the same way as the SSR-primary pattern:
// app/page.tsx
import { sdk, getOptimizationData } from '@/lib/optimization-server'
export default async function Home() {
const [baselineEntries, optimizationData] = await Promise.all([
fetchEntriesFromContentful(),
getOptimizationData(),
])
const resolvedEntries = baselineEntries.map((entry) => {
const { entry: resolved } = sdk.resolveOptimizedEntry(
entry,
optimizationData.selectedOptimizations,
)
return resolved
})
return (
<main>
<HybridEntryList baselineEntries={baselineEntries} serverResolvedEntries={resolvedEntries} />
</main>
)
}
HybridEntryList is a Client Component (described in step 6) that receives both the baseline entries and the server-resolved entries. It renders the server-resolved entries immediately, then switches to the client-resolved versions once the React Web SDK is ready.
Add data attributes for client-side tracking
Include data-ctfl-entry-id and data-ctfl-baseline-id on the wrapper element so the React Web SDK can register interaction trackers after hydration:
<div data-ctfl-entry-id={resolvedEntry.sys.id} data-ctfl-baseline-id={baselineEntry.sys.id}>
{resolvedEntry.fields.title}
</div>
5. Seed the React Web SDK with server-resolved state
The key difference from the SSR-primary pattern is passing defaults to OptimizationRoot. This seeds the client SDK with the profile and selectedOptimizations the server already resolved, which allows resolveEntry() calls in Client Components to return as soon as the client provider reaches readiness after hydration. In normal browser rendering, the React Web provider uses layout-effect initialization so ready children can mount before the first visible client paint.
Create the client wrapper component:
// components/ClientProviderWrapper.tsx
'use client'
import dynamic from 'next/dynamic'
import { Suspense, type ReactNode } from 'react'
import type {
Profile,
SelectedOptimizationArray,
ChangeArray,
} from '@contentful/optimization-react-web/api-schemas'
const OptimizationRoot = dynamic(
() =>
import('@contentful/optimization-react-web').then((mod) => ({
default: mod.OptimizationRoot,
})),
{ ssr: false },
)
const NextAppAutoPageTracker = dynamic(
() =>
import('@contentful/optimization-react-web/router/next-app').then((mod) => ({
default: mod.NextAppAutoPageTracker,
})),
{ ssr: false },
)
interface ClientProviderWrapperProps {
children: ReactNode
defaults?: {
profile?: Profile
selectedOptimizations?: SelectedOptimizationArray
changes?: ChangeArray
}
}
export function ClientProviderWrapper({ children, defaults }: ClientProviderWrapperProps) {
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"
defaults={defaults}
>
<Suspense>
<NextAppAutoPageTracker />
</Suspense>
{children}
</OptimizationRoot>
)
}
If local diagnostics need SDK state subscriptions that are attached as soon as SDK state exists and before the first automatically emitted page() event, use OptimizationRoot's onStatesReady prop to subscribe to states.eventStream or states.blockedEventStream.
Pass defaults from the server layout
The layout is a Server Component and can call getOptimizationData() to fetch the optimization state for the current request. Pass that data to ClientProviderWrapper as defaults:
// app/layout.tsx
import { ClientProviderWrapper } from '@/components/ClientProviderWrapper'
import { getOptimizationData } from '@/lib/optimization-server'
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const optimizationData = await getOptimizationData()
return (
<html lang="en">
<body>
<ClientProviderWrapper
defaults={{
profile: optimizationData.profile,
selectedOptimizations: optimizationData.selectedOptimizations,
changes: optimizationData.changes,
}}
>
{children}
</ClientProviderWrapper>
</body>
</html>
)
}
Because getOptimizationData() is wrapped with cache(), calling it in the layout and in a page Server Component on the same request makes only one API call to the Experience API.
6. Re-resolve entries client-side after hydration
Client Components that need to re-resolve entries when the profile changes subscribe to selectedOptimizations and call resolveEntry() directly:
// components/HybridEntry.tsx
'use client'
import { useEntryResolver, useOptimizationContext } from '@contentful/optimization-react-web'
import type { SelectedOptimizationArray } from '@contentful/optimization-react-web/api-schemas'
import { useEffect, useState } from 'react'
function HybridEntry({
baselineEntry,
serverResolvedEntry,
}: {
baselineEntry: ContentEntry
serverResolvedEntry: ContentEntry
}) {
const { sdk, isReady } = useOptimizationContext()
const { resolveEntry } = useEntryResolver()
const [selectedOptimizations, setSelectedOptimizations] = useState<
SelectedOptimizationArray | undefined
>(undefined)
useEffect(() => {
if (!sdk || !isReady) return
const subscription = sdk.states.selectedOptimizations.subscribe(setSelectedOptimizations)
return () => subscription.unsubscribe()
}, [sdk, isReady])
const clientReady = isReady && selectedOptimizations !== undefined
const resolvedEntry = clientReady
? resolveEntry(baselineEntry, selectedOptimizations)
: serverResolvedEntry
return (
<div data-ctfl-entry-id={resolvedEntry.sys.id} data-ctfl-baseline-id={baselineEntry.sys.id}>
<p>{resolvedEntry.fields.title}</p>
</div>
)
}
The component uses serverResolvedEntry as the initial render value. This ensures the content matches what the server rendered while the Web SDK initializes. Once isReady is true and selectedOptimizations is populated (either from defaults or from the Web SDK's first API call), the component switches to resolveEntry() for all subsequent resolution.
After this point, calling sdk.identify(), sdk.consent(), or sdk.reset() updates selectedOptimizations in the SDK state, which triggers the subscribe callback, which updates selectedOptimizations in component state, which causes resolveEntry() to run again with the updated data. The result is immediate content re-resolution without a server roundtrip.
Subscribing to selectedOptimizations
The subscription in the example above is the low-level imperative approach. It gives you full control over when to switch from server to client resolution, but requires more component state management.
Using OptimizedEntry for fully reactive entries
For pages or components where all entries should be client-side reactive (for example, an interactive dashboard that does not need the SSR-first-paint guarantee), use OptimizedEntry with liveUpdates enabled instead:
'use client'
import { OptimizedEntry } from '@contentful/optimization-react-web'
function ReactiveSection({ baselineEntry }) {
return (
<OptimizedEntry baselineEntry={baselineEntry} liveUpdates={true}>
{(resolved) => <Card entry={resolved} />}
</OptimizedEntry>
)
}
OptimizedEntry with liveUpdates={true} continuously re-resolves when selectedOptimizations changes. This is the right choice for sections of the page that do not need the SSR handoff logic.
7. Handle consent and identify from client components
Consent and identify controls are identical to the SSR-primary pattern. Create a Client Component that reads the SDK state and exposes 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>
)
}
In this pattern, unlike the SSR-primary pattern, calling sdk.identify() or sdk.consent() immediately updates selectedOptimizations in the client SDK, which causes all Client Components subscribed to that state to re-render with the new variant. No page refresh is required.
Understand when personalization updates
| User action | Effect on displayed content | When it takes effect |
|---|---|---|
| First page load | Server-resolved personalized HTML | Immediate (in HTML) |
| After hydration (same page) | No change — server content stays until SDK is ready | Seamless |
| Accept or reject consent | Client Components re-resolve with updated profile | Instant (client-side) |
Identify (sdk.identify()) |
Client Components re-resolve with updated profile | Instant (client-side) |
Reset (sdk.reset()) |
Client Components re-resolve with updated profile | Instant (client-side) |
Navigate via <Link> |
New page entries resolved client-side | Fast (no server roundtrip) |
| Browser refresh or full navigation | Back to server-resolved first paint | Immediate (new SSR) |
Known gap: redundant Experience API call on hydration
OptimizationRoot always initializes a fresh Web SDK instance that calls the Experience API to fetch selectedOptimizations. This is the same data the server already resolved and passed as defaults.
The defaults prop seeds the initial state so that resolveEntry() works immediately on first render. However, the Web SDK still makes its own API call in the background to establish a live, reactive state for subsequent profile changes.
Impact:
There is a brief window after hydration where both server-resolved defaults and the client's own API call may be in flight simultaneously. The defaults prop ensures content appears correct during this window (no flicker), but be aware that the client API call will overwrite the
default state once it resolves.
Mitigation:
If the Experience API call latency is a concern, test whether the defaults prop fully prevents any visible content change. In most cases, because the server and client resolve against the same profile (via the shared cookie), the API call returns the same selectedOptimizations and the effective displayed content is identical.
Choosing between SSR-primary and hybrid patterns per route
The App Router lets you choose the strategy per-route. You can use the Node SDK for Server Component routes and the React Web SDK for Client Component routes within the same application:
- Server Component routes (Node SDK): Homepage, landing pages, SEO-critical content. Use
sdk.resolveOptimizedEntry()for first-paint content. Best for routes where personalization decisions are based on stable profile traits, not real-time interactions. - Client Component routes (React Web SDK): interactive dashboards, account pages, flows where
identifyor consent changes must be reflected immediately. UseresolveEntry()orOptimizedEntrywithliveUpdates={true}.
Mixed-strategy applications are valid and can use a single ClientProviderWrapper in the root layout to provide the React Web SDK to all client-component subtrees.
The server and client SDK boundary
As with the SSR-primary pattern, keep this boundary strict:
- Server Components import only from
@contentful/optimization-node. - Client Components (
'use client') import only from@contentful/optimization-react-web.
Any file that imports from @contentful/optimization-react-web must begin with 'use client', or it must only be imported by files that do. Importing the React Web SDK in a Server Component causes runtime errors because the SDK accesses browser globals at import time.
Reference implementations to compare against
implementations/react-web-sdk+node-sdk_nextjs-ssr-csr: working Next.js App Router application using the hybrid SSR + CSR takeover pattern.middleware.ts: Edge Runtime cookie lifecyclelib/optimization-server.ts: Node SDK singleton withcache()-wrappedgetOptimizationData()app/layout.tsx: Server Component layout that fetches defaults and passes them to the client providerapp/page.tsx: Server Component page resolving entries for first paintcomponents/ClientProviderWrapper.tsx: dynamic React Web SDK provider withdefaultspropcomponents/HybridEntryList.tsx: Client Component switching between server-resolved and client-resolved entries.