diff --git a/documentation/guides/README.md b/documentation/guides/README.md
index 0d381752..58a70d08 100644
--- a/documentation/guides/README.md
+++ b/documentation/guides/README.md
@@ -6,6 +6,8 @@ children:
- ./integrating-the-web-sdk-in-a-web-app.md
- ./integrating-the-react-web-sdk-in-a-react-app.md
- ./integrating-the-react-native-sdk-in-a-react-native-app.md
+ - ./integrating-the-optimization-sdk-in-a-nextjs-app-ssr.md
+ - ./integrating-the-optimization-sdk-in-a-nextjs-app-ssr-csr.md
---
# Guides
@@ -33,3 +35,10 @@ inventory instead.
- [Integrating the Optimization React Native SDK in a React Native app](./integrating-the-react-native-sdk-in-a-react-native-app.md) -
step-by-step React Native / Expo integration guidance covering setup, consent, personalization and
interaction tracking, screen tracking, live updates, and the in-app preview panel
+- [Integrating the Optimization SDK in a Next.js app (SSR-primary)](./integrating-the-optimization-sdk-in-a-nextjs-app-ssr.md) -
+ step-by-step Next.js App Router guidance for the SSR-primary pattern where the Node SDK resolves
+ entries server-side and the React Web SDK handles client-side tracking and interactive controls
+- [Integrating the Optimization SDK in a Next.js app (hybrid SSR + CSR takeover)](./integrating-the-optimization-sdk-in-a-nextjs-app-ssr-csr.md) -
+ step-by-step Next.js App Router guidance for the hybrid pattern where first paint is
+ server-resolved and the React Web SDK takes over for instant client-side reactivity after
+ hydration
diff --git a/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr-csr.md b/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr-csr.md
new file mode 100644
index 00000000..33a6f80c
--- /dev/null
+++ b/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr-csr.md
@@ -0,0 +1,543 @@
+# Integrating the Optimization SDK in a Next.js app (hybrid SSR + CSR takeover)
+
+Use this guide when you want to 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](./integrating-the-optimization-sdk-in-a-nextjs-app-ssr.md) instead.
+
+
+ Table of Contents
+
+
+- [Scope and capabilities](#scope-and-capabilities)
+- [The integration flow](#the-integration-flow)
+- [1. Install the packages](#1-install-the-packages)
+- [2. Create the Node SDK singleton and a cached page helper](#2-create-the-node-sdk-singleton-and-a-cached-page-helper)
+- [3. Set up the anonymous ID cookie in middleware](#3-set-up-the-anonymous-id-cookie-in-middleware)
+- [4. Resolve entries on the server for first paint](#4-resolve-entries-on-the-server-for-first-paint)
+ - [Add data attributes for client-side tracking](#add-data-attributes-for-client-side-tracking)
+- [5. Seed the React Web SDK with server-resolved state](#5-seed-the-react-web-sdk-with-server-resolved-state)
+ - [Pass defaults from the server layout](#pass-defaults-from-the-server-layout)
+- [6. Re-resolve entries client-side after hydration](#6-re-resolve-entries-client-side-after-hydration)
+ - [Subscribing to selectedOptimizations](#subscribing-to-selectedoptimizations)
+ - [Using OptimizedEntry for fully reactive entries](#using-optimizedentry-for-fully-reactive-entries)
+- [7. Handle consent and identify from client components](#7-handle-consent-and-identify-from-client-components)
+- [Understand when personalization updates](#understand-when-personalization-updates)
+- [Known gap: redundant Experience API call on hydration](#known-gap-redundant-experience-api-call-on-hydration)
+- [Choosing between SSR-primary and hybrid patterns per route](#choosing-between-ssr-primary-and-hybrid-patterns-per-route)
+- [The server and client SDK boundary](#the-server-and-client-sdk-boundary)
+- [Reference implementations to compare against](#reference-implementations-to-compare-against)
+
+
+
+
+## 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 gives you:
+
+- No flicker on first paint. The initial HTML contains server-resolved personalized content.
+- Instant reactivity after hydration. Calling `sdk.identify()`, `sdk.consent()`, or `sdk.reset()`
+ immediately updates the resolved entries on screen.
+- SPA-style navigation. After the first server request, subsequent `` 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 give you (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 `useOptimization()` 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) | `autoTrackEntryInteraction` observes elements |
+| Consent / identify / reset | N/A | React Web SDK — triggers immediate re-resolution |
+
+In practice, the integration follows this sequence:
+
+1. Create one Node SDK instance shared across Server Components and middleware.
+2. Use Next.js middleware to maintain the anonymous ID cookie on every request.
+3. In the server layout, call `sdk.page()` once and pass the result as `defaults` into the client
+ provider.
+4. In Server Component pages, use `sdk.resolveOptimizedEntry()` for first-paint content.
+5. In Client Component pages or components, use `resolveEntry()` from `useOptimization()` for
+ reactive content.
+6. Load the React Web SDK with `next/dynamic` and `ssr: false`.
+7. Use Client Components for consent, identify, and any interactive SDK controls.
+
+The hybrid reference implementation in this repository shows that pattern in a working application:
+
+- [`implementations/react-web-sdk+node-sdk_nextjs-ssr-csr`](../../implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/README.md)
+
+## 1. Install the packages
+
+```sh
+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:
+
+```ts
+// 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:
+
+```ts
+// 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 {
+ 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:
+
+```tsx
+// 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 (
+
+
+
+ )
+}
+```
+
+`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:
+
+```tsx
+
+ {resolvedEntry.fields.title}
+
+```
+
+## 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 immediately on the first render after
+hydration.
+
+Create the client wrapper component:
+
+```tsx
+// 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 (
+
+
+
+
+ {children}
+
+ )
+}
+```
+
+### 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`:
+
+```tsx
+// 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 (
+
+
+
+ {children}
+
+
+
+ )
+}
+```
+
+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:
+
+```tsx
+// components/HybridEntry.tsx
+'use client'
+
+import { useOptimization, 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 } = useOptimization()
+ 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 (
+
+
{resolvedEntry.fields.title}
+
+ )
+}
+```
+
+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:
+
+```tsx
+'use client'
+
+import { OptimizedEntry } from '@contentful/optimization-react-web'
+
+function ReactiveSection({ baselineEntry }) {
+ return (
+
+ {(resolved) => }
+
+ )
+}
+```
+
+`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:
+
+```tsx
+// 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(undefined)
+
+ useEffect(() => {
+ if (!sdk || !isReady) return
+
+ const sub = sdk.states.consent.subscribe(setConsent)
+
+ return () => sub.unsubscribe()
+ }, [sdk, isReady])
+
+ if (!sdk || !isReady) return null
+
+ return (
+
+
+
+
+
+ )
+}
+```
+
+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 `` | 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
+ `identify` or consent changes must be reflected immediately. Use `resolveEntry()` or
+ `OptimizedEntry` with `liveUpdates={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`](../../implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/README.md):
+ working Next.js App Router application using the hybrid SSR + CSR takeover pattern.
+ - [`middleware.ts`](../../implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/middleware.ts):
+ Edge Runtime cookie lifecycle
+ - [`lib/optimization-server.ts`](../../implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/lib/optimization-server.ts):
+ Node SDK singleton with `cache()`-wrapped `getOptimizationData()`
+ - [`app/layout.tsx`](../../implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/app/layout.tsx):
+ Server Component layout that fetches defaults and passes them to the client provider
+ - [`app/page.tsx`](../../implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/app/page.tsx):
+ Server Component page resolving entries for first paint
+ - [`components/ClientProviderWrapper.tsx`](../../implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/components/ClientProviderWrapper.tsx):
+ dynamic React Web SDK provider with `defaults` prop
+ - [`components/HybridEntryList.tsx`](../../implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/components/HybridEntryList.tsx):
+ Client Component switching between server-resolved and client-resolved entries
diff --git a/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr.md b/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr.md
new file mode 100644
index 00000000..c9a7b6cb
--- /dev/null
+++ b/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr.md
@@ -0,0 +1,504 @@
+# Integrating the Optimization SDK in a Next.js app (SSR-primary)
+
+Use this guide when you want to 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 — use the
+[Hybrid SSR + CSR takeover guide](./integrating-the-optimization-sdk-in-a-nextjs-app-ssr-csr.md)
+instead.
+
+
+ Table of Contents
+
+
+- [Scope and capabilities](#scope-and-capabilities)
+- [The integration flow](#the-integration-flow)
+- [1. Install the packages](#1-install-the-packages)
+- [2. Create the Node SDK singleton](#2-create-the-node-sdk-singleton)
+- [3. Set up the anonymous ID cookie in middleware](#3-set-up-the-anonymous-id-cookie-in-middleware)
+- [4. Resolve entries in a Server Component](#4-resolve-entries-in-a-server-component)
+ - [Add data attributes for client-side tracking](#add-data-attributes-for-client-side-tracking)
+- [5. Load the React Web SDK on the client only](#5-load-the-react-web-sdk-on-the-client-only)
+ - [Why `ssr: false` is required](#why-ssr-false-is-required)
+ - [Add the page tracker](#add-the-page-tracker)
+- [6. Mount the client provider in your layout](#6-mount-the-client-provider-in-your-layout)
+- [7. Handle consent and identify from client components](#7-handle-consent-and-identify-from-client-components)
+- [Understand when personalization updates](#understand-when-personalization-updates)
+- [Caching considerations](#caching-considerations)
+- [The server and client SDK boundary](#the-server-and-client-sdk-boundary)
+- [Reference implementations to compare against](#reference-implementations-to-compare-against)
+
+
+
+
+## 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 (`autoTrackEntryInteraction`) |
+| 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:
+
+1. Create one Node SDK instance shared across Server Components and middleware.
+2. Use Next.js middleware to maintain the anonymous ID cookie on every request.
+3. In Server Components, read the cookie, call `sdk.page()`, and resolve each entry variant.
+4. Load the React Web SDK with `next/dynamic` and `ssr: false` so it only runs in the browser.
+5. Mount the React Web SDK provider and page tracker in a `'use client'` wrapper in your layout.
+6. Use Client Components for consent, identify, and any interactive SDK controls.
+
+The SSR-primary reference implementation in this repository shows that pattern in a working
+application:
+
+- [`implementations/react-web-sdk+node-sdk_nextjs-ssr`](../../implementations/react-web-sdk+node-sdk_nextjs-ssr/README.md)
+
+## 1. Install the packages
+
+```sh
+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.
+
+```ts
+// 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.
+
+```ts
+// 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 {
+ 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.
+
+Do not mark this cookie as `HttpOnly`. The Web SDK reads it from the browser.
+
+Why the middleware calls `sdk.page()` in addition to the Server Component doing the same: 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:
+
+```tsx
+// 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 (
+
+ {resolvedEntries.map((entry) => (
+
+ ))}
+
+ )
+}
+```
+
+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 `autoTrackEntryInteraction` 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:
+
+```tsx
+function ServerRenderedEntry({
+ baselineEntry,
+ resolvedEntry,
+}: {
+ baselineEntry: ContentEntry
+ resolvedEntry: ContentEntry
+}) {
+ return (
+
+
{resolvedEntry.fields.title}
+
+ )
+}
+```
+
+`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:
+
+```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 },
+)
+```
+
+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.
+
+### Add the page tracker
+
+Mount `NextAppAutoPageTracker` inside `OptimizationRoot` to emit `page()` events automatically
+whenever the App Router pathname changes:
+
+```tsx
+const NextAppAutoPageTracker = dynamic(
+ () =>
+ import('@contentful/optimization-react-web/router/next-app').then((mod) => ({
+ default: mod.NextAppAutoPageTracker,
+ })),
+ { ssr: false },
+)
+```
+
+Wrap the tracker in `` to prevent it from blocking the initial render.
+
+## 6. Mount the client provider in your layout
+
+Assemble the client wrapper component and use it in `app/layout.tsx`:
+
+```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 (
+
+
+
+
+ {children}
+
+ )
+}
+```
+
+```tsx
+// app/layout.tsx
+import { ClientProviderWrapper } from '@/components/ClientProviderWrapper'
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+
+ {children}
+
+
+ )
+}
+```
+
+`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:
+
+```sh
+# 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:
+
+```tsx
+// 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(undefined)
+
+ useEffect(() => {
+ if (!sdk || !isReady) return
+
+ const sub = sdk.states.consent.subscribe(setConsent)
+
+ return () => sub.unsubscribe()
+ }, [sdk, isReady])
+
+ if (!sdk || !isReady) return null
+
+ return (
+
+
+
+
+
+ )
+}
+```
+
+`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 of `selectedOptimizations`. 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`](../../implementations/react-web-sdk+node-sdk_nextjs-ssr/README.md):
+ 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`](../../implementations/react-web-sdk+node-sdk_nextjs-ssr/middleware.ts): Edge
+ Runtime cookie lifecycle
+ - [`lib/optimization-server.ts`](../../implementations/react-web-sdk+node-sdk_nextjs-ssr/lib/optimization-server.ts):
+ Node SDK singleton
+ - [`app/page.tsx`](../../implementations/react-web-sdk+node-sdk_nextjs-ssr/app/page.tsx): Server
+ Component fetching entries and resolving variants
+ - [`components/ClientProviderWrapper.tsx`](../../implementations/react-web-sdk+node-sdk_nextjs-ssr/components/ClientProviderWrapper.tsx):
+ dynamic React Web SDK provider
+ - [`components/InteractiveControls.tsx`](../../implementations/react-web-sdk+node-sdk_nextjs-ssr/components/InteractiveControls.tsx):
+ Client Component for consent, identify, and reset
diff --git a/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/.env.example b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/.env.example
new file mode 100644
index 00000000..9273d7d6
--- /dev/null
+++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/.env.example
@@ -0,0 +1,16 @@
+DOTENV_CONFIG_QUIET=true
+
+PUBLIC_NINETAILED_CLIENT_ID="mock-client-id"
+PUBLIC_NINETAILED_ENVIRONMENT="main"
+
+PUBLIC_EXPERIENCE_API_BASE_URL="http://localhost:8000/experience/"
+PUBLIC_INSIGHTS_API_BASE_URL="http://localhost:8000/insights/"
+
+PUBLIC_CONTENTFUL_TOKEN="mock-token"
+PUBLIC_CONTENTFUL_PREVIEW_TOKEN="mock-preview-token"
+PUBLIC_CONTENTFUL_ENVIRONMENT="master"
+PUBLIC_CONTENTFUL_SPACE_ID="mock-space-id"
+
+PUBLIC_CONTENTFUL_CDA_HOST="localhost:8000"
+PUBLIC_CONTENTFUL_BASE_PATH="contentful"
+PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL="false"
diff --git a/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/.gitignore b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/.gitignore
new file mode 100644
index 00000000..818f88a7
--- /dev/null
+++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/.gitignore
@@ -0,0 +1,42 @@
+# dependencies
+/node_modules
+/.pnp
+.pnp.*
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/versions
+
+# testing
+/coverage
+/playwright-report
+/test-results
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# env files
+.env
+.env*.local
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
diff --git a/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/.npmrc b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/.npmrc
new file mode 100644
index 00000000..135f7a0d
--- /dev/null
+++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/.npmrc
@@ -0,0 +1 @@
+shared-workspace-lockfile=false
diff --git a/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/AGENTS.md b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/AGENTS.md
new file mode 100644
index 00000000..84be558f
--- /dev/null
+++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/AGENTS.md
@@ -0,0 +1,55 @@
+# AGENTS.md
+
+Read the repository root `AGENTS.md` first.
+
+## Scope
+
+This is the Next.js Hybrid SSR + CSR Takeover reference implementation. It combines
+`@contentful/optimization-node` for server-side first-paint resolution with
+`@contentful/optimization-react-web` for client-side reactivity after hydration.
+
+This represents a customer setup where:
+
+- First paint is server-resolved (no flicker)
+- After hydration, the React Web SDK takes over for entry resolution
+- Identify, consent, and reset re-resolve entries immediately (no server roundtrip)
+- Subsequent navigations via `` are SPA-style (client-resolved)
+- Some routes are Server Components (SSR-resolved), others are Client Components (CSR-resolved)
+
+Contrast with (`react-web-sdk+node-sdk_nextjs-ssr`) where content is static until the next server
+roundtrip — there the React Web SDK only handles events/tracking, never entry resolution.
+
+## Key Paths
+
+- `app/` — Next.js App Router (mix of Server and Client Components)
+- `lib/` — SDK config, Contentful client, Node SDK singleton
+- `components/` — ClientProviderWrapper, client-resolved page components
+- `middleware.ts` — cookie lifecycle (same as )
+- `.env.example`
+
+## Local Rules
+
+- Next.js App Router only. No Pages Router.
+- Server Components import only from `@contentful/optimization-node`.
+- Client Components (`"use client"`) import only from `@contentful/optimization-react-web`.
+- Landing/SEO pages should be Server Components with Node SDK resolution.
+- Interactive/reactive pages should be Client Components using `` or
+ `resolveEntry()`.
+- Use `liveUpdates={true}` on `` for entries that should re-resolve on profile
+ change.
+- Use the SDK's `OptimizationRoot` directly — no custom provider wrappers around it.
+- If you changed a consumed package, run `pnpm build:pkgs` and reinstall before trusting results.
+
+## Commands
+
+- `pnpm implementation:run -- react-web-sdk+node-sdk_nextjs-ssr-csr implementation:install`
+- `pnpm implementation:run -- react-web-sdk+node-sdk_nextjs-ssr-csr typecheck`
+- `pnpm implementation:run -- react-web-sdk+node-sdk_nextjs-ssr-csr build`
+- `pnpm implementation:run -- react-web-sdk+node-sdk_nextjs-ssr-csr dev`
+- `pnpm implementation:run -- react-web-sdk+node-sdk_nextjs-ssr-csr serve`
+- `pnpm implementation:run -- react-web-sdk+node-sdk_nextjs-ssr-csr serve:stop`
+
+## Usually Validate
+
+- Run `typecheck` for local code changes.
+- Run `build` when changing production bundling behavior.
diff --git a/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/README.md b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/README.md
new file mode 100644
index 00000000..f6f605c9
--- /dev/null
+++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/README.md
@@ -0,0 +1,233 @@
+# Next.js Hybrid SSR + CSR Takeover Reference Implementation
+
+`react-web-sdk+node-sdk_nextjs-ssr-csr` — Next.js App Router reference demonstrating the "SSR → CSR
+Takeover" pattern. First paint is server-resolved via the Node SDK (no flicker), after hydration the
+React Web SDK takes over for reactive entry resolution and SPA-style navigation.
+
+## Pattern: Hybrid SSR + CSR Takeover
+
+This is setup is the first page load is fully server-resolved (identical to nextJs-ssr), but after
+hydration the React Web SDK takes over. Subsequent navigations, identify, consent, and profile
+changes all re-resolve entries client-side without a server roundtrip.
+
+### Why this pattern?
+
+- **No flicker on first paint.** The initial HTML contains server-resolved personalized content
+- **Instant reactivity after hydration.** Identify, consent, reset all re-resolve entries
+ immediately — no page refresh needed.
+- **SPA-style navigation.** After first paint, `` navigations resolve variants client-side
+ (faster, no server roundtrip).
+- **Best of both worlds.** Combines the SEO and first-paint benefits of SSR with the reactivity of
+ CSR.
+- **No Next.js SDK needed.** Achievable today with the Node SDK + React Web SDK composition.
+
+### Trade-offs
+
+- **Higher complexity than nextJs-ssr.** Must manage both server-side resolution (Server Components)
+ and client-side resolution (Client Components with `resolveEntry()`).
+- **Two resolution paths.** First paint uses `sdk.resolveOptimizedEntry()` on the server; subsequent
+ interactions use `resolveEntry()` on the client via the `useOptimization()` hook.
+- **State handoff gap.** `OptimizationProvider` cannot currently accept pre-fetched server data — it
+ always initializes fresh and calls the Experience API from the browser. This means the client SDK
+ makes a redundant API call on hydration to get the same `selectedOptimizations` the server already
+ resolved.
+
+### Responsibility split
+
+| 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 `useOptimization()` hook |
+| Entry fetching | Server-side from CDA | Client-side from CDA (for new routes) |
+| Page tracking | N/A | `NextAppAutoPageTracker` fires on route change |
+| Interaction tracking | N/A (data attributes rendered server-side) | `autoTrackEntryInteraction` observes elements |
+| Consent / Identify / Reset | N/A | React Web SDK — triggers immediate re-resolution |
+
+### Behavioral expectations
+
+| Phase | Content behavior |
+| -------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
+| First page load | Server-resolved personalized HTML (no flicker, no loading state) |
+| After hydration | React Web SDK initializes, fires page event, starts tracking |
+| User identifies | `sdk.identify()` → `selectedOptimizations` updates → entries re-resolve instantly |
+| User grants consent | `sdk.consent()` → re-resolution if optimization rules depend on consent state |
+| Client-side navigation (``) | `NextAppAutoPageTracker` fires page event → new entries fetched client-side → resolved via `resolveEntry()` |
+| Full page navigation (browser refresh) | Back to server-resolved first paint |
+
+## Architecture
+
+```
+┌─────────────────────────────────────────────────────────────────────┐
+│ FIRST REQUEST (Server — identical to nextJs-ssr) │
+│ │
+│ 1. Middleware (Edge Runtime) │
+│ ├─ Read `ctfl-opt-aid` cookie from request │
+│ ├─ Call Node SDK `sdk.page()` with request context + profile │
+│ └─ Set `ctfl-opt-aid` cookie on response with profile.id │
+│ │
+│ 2. Server Component (landing page) │
+│ ├─ Read `ctfl-opt-aid` cookie │
+│ ├─ Fetch entries from CDA + call `sdk.page()` in parallel │
+│ ├─ `sdk.resolveOptimizedEntry()` for each entry │
+│ └─ Render personalized HTML (zero client JS for content) │
+│ │
+│ ↓ HTML response with personalized content │
+└─────────────────────────────────────────────────────────────────────┘
+
+┌─────────────────────────────────────────────────────────────────────┐
+│ HYDRATION + SPA TAKEOVER (Browser) │
+│ │
+│ 3. ClientProviderWrapper (dynamic, ssr: false) │
+│ ├─ OptimizationRoot initializes Web SDK │
+│ ├─ Reads `ctfl-opt-aid` cookie → same identity as server │
+│ ├─ Calls Experience API → gets selectedOptimizations │
+│ └─ NextAppAutoPageTracker fires initial page view │
+│ │
+│ 4. Subsequent navigations (client-side via ) │
+│ ├─ NextAppAutoPageTracker fires page event for new route │
+│ ├─ Client Component fetches entries from CDA │
+│ ├─ resolveEntry() resolves with current selectedOptimizations │
+│ └─ React renders personalized content (no server roundtrip) │
+│ │
+│ 5. User actions (identify, consent, reset) │
+│ ├─ sdk.identify() / sdk.consent() / sdk.reset() │
+│ ├─ selectedOptimizations updates reactively │
+│ └─ resolveEntry() returns updated variant immediately │
+└─────────────────────────────────────────────────────────────────────┘
+```
+
+## Key implementation patterns
+
+### 1. Landing page is a Server Component (same as nextJs-ssr)
+
+The first page the user hits resolves entries server-side. No loading state, no flicker:
+
+```typescript
+// app/page.tsx (Server Component)
+const { entry: resolved } = sdk.resolveOptimizedEntry(entry, optimizationData.selectedOptimizations)
+```
+
+### 2. Subsequent pages use Client Components with `resolveEntry()`
+
+After hydration, navigating to other routes fetches entries client-side and resolves them via the
+React Web SDK's `useOptimization()` hook:
+
+```typescript
+// app/other-page/page.tsx ("use client")
+import { useOptimization } from '@contentful/optimization-react-web'
+
+function PersonalizedSection({ entry }) {
+ const { resolveEntry } = useOptimization()
+ const resolvedEntry = resolveEntry(entry)
+
+ return
{resolvedEntry.fields.text}
+}
+```
+
+### 3. Reactive re-resolution via `resolveEntry()`
+
+When the user's profile changes (identify, consent, reset), `resolveEntry()` automatically returns
+the updated variant because it reads from the reactive `selectedOptimizations` state. The component
+re-renders with the new content — no manual state management or `liveUpdates` flag needed:
+
+```typescript
+function ClientResolvedEntry({ entry }) {
+ const { resolveEntry } = useOptimization()
+ const resolvedEntry = resolveEntry(entry) // re-resolves on profile changes
+ return
+}
+```
+
+### 4. Cookie bridge (same as nextJs-ssr)
+
+Middleware creates `ctfl-opt-aid`, Server Components read it, and the Web SDK picks it up from
+`document.cookie` on hydration. Same identity across server and client.
+
+### 5. `` for SPA navigation
+
+Using Next.js `` avoids full page reloads. The `NextAppAutoPageTracker` detects route changes
+and fires page events, which may update `selectedOptimizations` if the new page context matches
+different audience rules.
+
+### 6. Mixed route strategy
+
+Some routes can be Server Components (SSR-resolved), others can be Client Components (CSR-resolved).
+This is a natural capability of the Next.js App Router — you choose per-route:
+
+- **High-SEO pages** (homepage, landing pages): Server Component + Node SDK resolution
+- **Interactive pages** (dashboard, account): Client Component + React Web SDK resolution
+
+## Known gap: redundant API call on hydration
+
+The `OptimizationRoot` always initializes a fresh Web SDK instance that calls the Experience API to
+get `selectedOptimizations`. This is the same data the server already resolved. Currently there is
+no way to pass server-resolved optimization data into the client provider to skip this call.
+
+**Impact:** Slight delay after hydration before client-side resolution is ready. The server-rendered
+content remains visible (no flicker), but client-side reactivity only activates after the Web SDK's
+API call completes.
+
+**Future solution:** An `initialOptimizationData` prop on `OptimizationRoot` that seeds the SDK
+state without a redundant API call. This would make the SSR → CSR handoff seamless.
+
+## When does the user see updated personalization?
+
+| User action | Effect | Timing |
+| --------------------------------- | --------------------------------------- | -------------------------- |
+| First page load | Server-resolved personalized content | Immediate (in HTML) |
+| After hydration (same page) | No change — server content stays | Seamless |
+| Identify / consent / reset | Entries re-resolve via `resolveEntry()` | Instant (client-side) |
+| Navigate via `` | New page entries resolved client-side | Fast (no server roundtrip) |
+| Browser refresh / full navigation | Back to server-resolved first paint | Immediate (new SSR) |
+
+## Comparison with nextJs-ssr
+
+| | nextJs-ssr (SSR + Events-Only) | (Hybrid SSR + CSR Takeover) |
+| ------------------------- | ----------------------------------- | ------------------------------ |
+| **First paint** | Personalized (server-resolved) | Personalized (server-resolved) |
+| **After identify** | No change until next server request | Immediate re-resolution |
+| **Subsequent navigation** | Full server roundtrip | Client-side (SPA) |
+| **Complexity** | Lower (server is sole truth) | Higher (two resolution paths) |
+| **Node SDK** | Required | Required (first paint only) |
+| **React Web SDK role** | Events/tracking only | Events + entry resolution |
+| **Content reactivity** | Static | Live |
+
+## When to use this pattern
+
+- Marketing sites that need both SEO (first paint) AND instant personalization reactions (after
+ identify)
+- Sites with multi-page flows where subsequent navigations should feel like an SPA
+- Customer setups that want "Welcome back, [name]!" to appear immediately after identification
+ without a page refresh
+- Teams already using nextJs-ssr who need to add client-side reactivity for specific pages
+
+## When NOT to use this pattern
+
+- If you never need client-side reactivity — use nextJs-ssr (simpler, server is sole truth)
+- If your site is a pure SPA with no server rendering — use the React Web SDK directly (see
+ `react-web-sdk` implementation)
+- If the redundant API call on hydration is unacceptable and the `initialOptimizationData` gap
+ hasn't been resolved yet
+
+## Setup
+
+```bash
+pnpm build:pkgs
+pnpm implementation:run -- react-web-sdk+node-sdk_nextjs-ssr-csr implementation:install
+cp implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/.env.example implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/.env
+```
+
+## Development
+
+```bash
+pnpm implementation:run -- react-web-sdk+node-sdk_nextjs-ssr-csr dev
+```
+
+## Related
+
+- [nextJs-ssr: SSR + Events-Only](../react-web-sdk+node-sdk_nextjs-ssr/README.md) — Server resolves
+ content, client tracks events only. Content is static until next server request.
+- [React Web SDK (pure CSR, non-Next.js)](../react-web-sdk/README.md) — Pure client-side
+ personalization without any server involvement.
+- [Web SDK + React (custom adapter)](../web-sdk_react/README.md) — CSR with hand-rolled adapter
+ layer on the raw Web SDK.
diff --git a/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/app/favicon.ico b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/app/favicon.ico
new file mode 100644
index 00000000..718d6fea
Binary files /dev/null and b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/app/favicon.ico differ
diff --git a/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/app/globals.css b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/app/globals.css
new file mode 100644
index 00000000..d4b50785
--- /dev/null
+++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/app/globals.css
@@ -0,0 +1 @@
+@import 'tailwindcss';
diff --git a/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/app/layout.tsx b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/app/layout.tsx
new file mode 100644
index 00000000..17eb09d8
--- /dev/null
+++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/app/layout.tsx
@@ -0,0 +1,34 @@
+import { ClientProviderWrapper } from '@/components/ClientProviderWrapper'
+import { getOptimizationData } from '@/lib/optimization-server'
+import type { Metadata } from 'next'
+import './globals.css'
+
+export const metadata: Metadata = {
+ title: 'Optimization Next.js Hybrid SSR + CSR Takeover',
+ description:
+ 'Next.js App Router reference: Node SDK resolves entries server-side for first paint, React SDK takes over for client-side reactivity and SPA navigation.',
+}
+
+export default async function RootLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode
+}>) {
+ const optimizationData = await getOptimizationData()
+
+ return (
+
+
+
+ {children}
+
+
+
+ )
+}
diff --git a/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/app/page.tsx b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/app/page.tsx
new file mode 100644
index 00000000..bb56b9f2
--- /dev/null
+++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/app/page.tsx
@@ -0,0 +1,41 @@
+import { HybridEntryList } from '@/components/HybridEntryList'
+import { InteractiveControls } from '@/components/InteractiveControls'
+import { ENTRY_IDS } from '@/config/entries'
+import { fetchEntries } from '@/lib/contentful-client'
+import { getOptimizationData, sdk } from '@/lib/optimization-server'
+import type { ContentEntry } from '@/types/contentful'
+
+export default async function Home() {
+ const [baselineEntries, optimizationData] = await Promise.all([
+ fetchEntries(ENTRY_IDS),
+ getOptimizationData(),
+ ])
+
+ const resolvedEntries = baselineEntries.map((entry: ContentEntry) => {
+ const { entry: resolved } = sdk.resolveOptimizedEntry(
+ entry,
+ optimizationData.selectedOptimizations,
+ )
+ return resolved
+ })
+
+ return (
+
+
Next.js Hybrid SSR + CSR Takeover
+
+ This page is server-resolved on first paint. After hydration, the React Web SDK takes over
+ and re-resolves entries client-side on identify, consent, or reset.
+