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.
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.
<Link> navigations resolve variants client-side
(faster, no server roundtrip).resolveEntry()).sdk.resolveOptimizedEntry() on the server; subsequent
interactions use resolveEntry() on the client via the useOptimization() hook.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.| 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 |
| 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 (<Link>) |
NextAppAutoPageTracker fires page event → new entries fetched client-side → resolved via resolveEntry() |
| Full page navigation (browser refresh) | Back to server-resolved first paint |
┌─────────────────────────────────────────────────────────────────────┐
│ 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 <Link>) │
│ ├─ 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 │
└─────────────────────────────────────────────────────────────────────┘
The first page the user hits resolves entries server-side. No loading state, no flicker:
// app/page.tsx (Server Component)
const { entry: resolved } = sdk.resolveOptimizedEntry(entry, optimizationData.selectedOptimizations)
resolveEntry()After hydration, navigating to other routes fetches entries client-side and resolves them via the
React Web SDK's useOptimization() hook:
// 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 <div>{resolvedEntry.fields.text}</div>
}
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:
function ClientResolvedEntry({ entry }) {
const { resolveEntry } = useOptimization()
const resolvedEntry = resolveEntry(entry) // re-resolves on profile changes
return <Content entry={resolvedEntry} />
}
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.
<Link> for SPA navigationUsing Next.js <Link> 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.
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:
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.
| 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 <Link> |
New page entries resolved client-side | Fast (no server roundtrip) |
| Browser refresh / full navigation | Back to server-resolved first paint | Immediate (new 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 |
react-web-sdk implementation)initialOptimizationData gap
hasn't been resolved yetpnpm 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
pnpm implementation:run -- react-web-sdk+node-sdk_nextjs-ssr-csr dev