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. +

+ + + +
+

Entries

+ +
+
+ ) +} diff --git a/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/components/ClientProviderWrapper.tsx b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/components/ClientProviderWrapper.tsx new file mode 100644 index 00000000..e8f7daf4 --- /dev/null +++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/components/ClientProviderWrapper.tsx @@ -0,0 +1,57 @@ +'use client' + +import { optimizationConfig } from '@/lib/config' +import type { + ChangeArray, + Profile, + SelectedOptimizationArray, +} from '@contentful/optimization-react-web/api-schemas' +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 }, +) + +interface ClientProviderWrapperProps { + readonly children: ReactNode + readonly defaults?: { + profile?: Profile + selectedOptimizations?: SelectedOptimizationArray + changes?: ChangeArray + } +} + +export function ClientProviderWrapper({ children, defaults }: ClientProviderWrapperProps) { + return ( + + + + + {children} + + ) +} diff --git a/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/components/HybridEntryList.tsx b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/components/HybridEntryList.tsx new file mode 100644 index 00000000..8c831f73 --- /dev/null +++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/components/HybridEntryList.tsx @@ -0,0 +1,80 @@ +'use client' + +import type { ContentEntry } from '@/types/contentful' +import { useOptimization, useOptimizationContext } from '@contentful/optimization-react-web' +import type { SelectedOptimizationArray } from '@contentful/optimization-react-web/api-schemas' +import { type JSX, useEffect, useState } from 'react' + +function getEntryText(entry: ContentEntry): string { + return typeof entry.fields.text === 'string' ? entry.fields.text : 'No content' +} + +function HybridEntry({ + baselineEntry, + serverResolvedEntry, +}: { + baselineEntry: ContentEntry + serverResolvedEntry: ContentEntry +}): JSX.Element { + 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((value) => { + setSelectedOptimizations(value) + }) + + return () => { + subscription.unsubscribe() + } + }, [sdk, isReady]) + + const clientReady = isReady && selectedOptimizations !== undefined + const resolvedEntry = clientReady + ? resolveEntry(baselineEntry, selectedOptimizations) + : serverResolvedEntry + + return ( +
+

{getEntryText(resolvedEntry)}

+
+ ) +} + +interface HybridEntryListProps { + baselineEntries: ContentEntry[] + serverResolvedEntries: ContentEntry[] +} + +export function HybridEntryList({ + baselineEntries, + serverResolvedEntries, +}: HybridEntryListProps): JSX.Element { + if (baselineEntries.length === 0) { + return

No entries found.

+ } + + return ( +
+ {baselineEntries.map((entry, index) => ( + + ))} +
+ ) +} diff --git a/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/components/InteractiveControls.tsx b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/components/InteractiveControls.tsx new file mode 100644 index 00000000..d6f1c4f3 --- /dev/null +++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/components/InteractiveControls.tsx @@ -0,0 +1,90 @@ +'use client' + +import { useOptimizationContext } from '@contentful/optimization-react-web' +import type { Profile } from '@contentful/optimization-react-web/api-schemas' +import { type JSX, useEffect, useMemo, useState } from 'react' + +export function InteractiveControls(): JSX.Element { + const { sdk, isReady } = useOptimizationContext() + const [consent, setConsent] = useState(undefined) + const [profile, setProfile] = useState(undefined) + + useEffect(() => { + if (!sdk || !isReady) { + return + } + + const consentSub = sdk.states.consent.subscribe((value: boolean | undefined) => { + setConsent(value) + }) + + const profileSub = sdk.states.profile.subscribe((value: Profile | undefined) => { + setProfile(value) + }) + + return () => { + consentSub.unsubscribe() + profileSub.unsubscribe() + } + }, [isReady, sdk]) + + const isIdentified = useMemo( + () => profile !== undefined && Boolean(profile.traits.identified), + [profile], + ) + + if (!sdk || !isReady) { + return ( +
+

SDK loading...

+
+ ) + } + + return ( +
+

Controls

+
+ + + {!isIdentified ? ( + + ) : ( + + )} +
+ +
+

Consent: {String(consent)}

+

Identified: {isIdentified ? 'Yes' : 'No'}

+
+
+ ) +} diff --git a/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/config/entries.ts b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/config/entries.ts new file mode 100644 index 00000000..72962fff --- /dev/null +++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/config/entries.ts @@ -0,0 +1,17 @@ +export const AUTO_OBSERVED_ENTRY_IDS = [ + '1JAU028vQ7v6nB2swl3NBo', + '1MwiFl4z7gkwqGYdvCmr8c', + '4ib0hsHWoSOnCVdDkizE8d', + 'xFwgG3oNaOcjzWiGe4vXo', + '2Z2WLOx07InSewC3LUB3eX', +] as const + +export const MANUALLY_OBSERVED_ENTRY_IDS = [ + '5XHssysWUDECHzKLzoIsg1', + '6zqoWXyiSrf0ja7I2WGtYj', + '7pa5bOx8Z9NmNcr7mISvD', +] as const + +export const ENTRY_IDS = [...AUTO_OBSERVED_ENTRY_IDS, ...MANUALLY_OBSERVED_ENTRY_IDS] as const + +export const LIVE_UPDATES_ENTRY_ID = '2Z2WLOx07InSewC3LUB3eX' as const diff --git a/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/eslint.config.mjs b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/eslint.config.mjs new file mode 100644 index 00000000..aeea6e4f --- /dev/null +++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/eslint.config.mjs @@ -0,0 +1,27 @@ +import nextVitals from 'eslint-config-next/core-web-vitals' +import nextTs from 'eslint-config-next/typescript' +import { defineConfig, globalIgnores } from 'eslint/config' + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + '.next/**', + 'out/**', + 'build/**', + 'next-env.d.ts', + ]), + { + settings: { + // Fix for ESLint 10+: eslint-plugin-react uses context.getFilename() (legacy API) (this package is used by eslint-config-nex) + // which was removed in ESLint 10 flat config. Declaring the version explicitly + // prevents the plugin from trying to auto-detect it and failing. + // but we will still get lint errors so we need the main lint project to ignore this for now + react: { version: '19' }, + }, + }, +]) + +export default eslintConfig diff --git a/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/lib/config.ts b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/lib/config.ts new file mode 100644 index 00000000..5abfe222 --- /dev/null +++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/lib/config.ts @@ -0,0 +1,23 @@ +const CLIENT_ID = process.env.PUBLIC_NINETAILED_CLIENT_ID?.trim() ?? 'mock-client-id' +const ENVIRONMENT = process.env.PUBLIC_NINETAILED_ENVIRONMENT?.trim() ?? 'main' +const INSIGHTS_BASE_URL = + process.env.PUBLIC_INSIGHTS_API_BASE_URL?.trim() ?? 'http://localhost:8000/insights/' +const EXPERIENCE_BASE_URL = + process.env.PUBLIC_EXPERIENCE_API_BASE_URL?.trim() ?? 'http://localhost:8000/experience/' + +export const optimizationConfig = { + clientId: CLIENT_ID, + environment: ENVIRONMENT, + api: { + insightsBaseUrl: INSIGHTS_BASE_URL, + experienceBaseUrl: EXPERIENCE_BASE_URL, + }, +} as const + +export const contentfulConfig = { + accessToken: process.env.PUBLIC_CONTENTFUL_TOKEN?.trim() ?? '', + environment: process.env.PUBLIC_CONTENTFUL_ENVIRONMENT?.trim() ?? '', + host: process.env.PUBLIC_CONTENTFUL_CDA_HOST?.trim() ?? '', + space: process.env.PUBLIC_CONTENTFUL_SPACE_ID?.trim() ?? '', + basePath: process.env.PUBLIC_CONTENTFUL_BASE_PATH?.trim(), +} as const diff --git a/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/lib/contentful-client.ts b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/lib/contentful-client.ts new file mode 100644 index 00000000..aa0b99c4 --- /dev/null +++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/lib/contentful-client.ts @@ -0,0 +1,34 @@ +import type { ContentEntry, ContentEntrySkeleton } from '@/types/contentful' +import { createClient } from 'contentful' +import { contentfulConfig } from './config' + +const INCLUDE_DEPTH = 10 + +function createContentfulClient(): ReturnType { + return createClient({ + accessToken: contentfulConfig.accessToken, + environment: contentfulConfig.environment, + host: contentfulConfig.host, + insecure: contentfulConfig.host.includes('localhost'), + space: contentfulConfig.space, + ...(contentfulConfig.basePath ? { basePath: contentfulConfig.basePath } : {}), + }) +} + +const contentfulClient = createContentfulClient() + +export async function fetchEntry(entryId: string): Promise { + try { + return await contentfulClient.getEntry(entryId, { + include: INCLUDE_DEPTH, + }) + } catch { + return undefined + } +} + +export async function fetchEntries(entryIds: readonly string[]): Promise { + const fetchedEntries = await Promise.all(entryIds.map(fetchEntry)) + + return fetchedEntries.filter((entry): entry is ContentEntry => entry !== undefined) +} diff --git a/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/lib/optimization-server.ts b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/lib/optimization-server.ts new file mode 100644 index 00000000..9b73d903 --- /dev/null +++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/lib/optimization-server.ts @@ -0,0 +1,32 @@ +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' +import { optimizationConfig } from './config' + +const sdk = new ContentfulOptimization({ + clientId: optimizationConfig.clientId, + environment: optimizationConfig.environment, + logLevel: 'debug', + api: optimizationConfig.api, + app: { + name: 'ContentfulOptimization SDK - Next.js SSR+CSR Hybrid (Server)', + version: '0.1.0', + }, +}) + +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 { getOptimizationData, sdk } diff --git a/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/middleware.ts b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/middleware.ts new file mode 100644 index 00000000..3e37b251 --- /dev/null +++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/middleware.ts @@ -0,0 +1,37 @@ +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).*)'], +} diff --git a/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/next.config.ts b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/next.config.ts new file mode 100644 index 00000000..9333b88a --- /dev/null +++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/next.config.ts @@ -0,0 +1,20 @@ +import type { NextConfig } from 'next' + +const nextConfig: NextConfig = { + serverExternalPackages: ['@contentful/optimization-node'], + env: { + PUBLIC_NINETAILED_CLIENT_ID: process.env.PUBLIC_NINETAILED_CLIENT_ID, + PUBLIC_NINETAILED_ENVIRONMENT: process.env.PUBLIC_NINETAILED_ENVIRONMENT, + PUBLIC_EXPERIENCE_API_BASE_URL: process.env.PUBLIC_EXPERIENCE_API_BASE_URL, + PUBLIC_INSIGHTS_API_BASE_URL: process.env.PUBLIC_INSIGHTS_API_BASE_URL, + PUBLIC_CONTENTFUL_TOKEN: process.env.PUBLIC_CONTENTFUL_TOKEN, + PUBLIC_CONTENTFUL_PREVIEW_TOKEN: process.env.PUBLIC_CONTENTFUL_PREVIEW_TOKEN, + PUBLIC_CONTENTFUL_ENVIRONMENT: process.env.PUBLIC_CONTENTFUL_ENVIRONMENT, + PUBLIC_CONTENTFUL_SPACE_ID: process.env.PUBLIC_CONTENTFUL_SPACE_ID, + PUBLIC_CONTENTFUL_CDA_HOST: process.env.PUBLIC_CONTENTFUL_CDA_HOST, + PUBLIC_CONTENTFUL_BASE_PATH: process.env.PUBLIC_CONTENTFUL_BASE_PATH, + PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL: process.env.PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL, + }, +} + +export default nextConfig diff --git a/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/package.json b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/package.json new file mode 100644 index 00000000..1412d58b --- /dev/null +++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/package.json @@ -0,0 +1,68 @@ +{ + "name": "@implementation/react-web-sdk+node-sdk_nextjs-ssr-csr", + "private": true, + "version": "0.0.0", + "description": "Reference implementation for Next.js (App Router) using @contentful/optimization-node for SSR first-paint and @contentful/optimization-react-web for CSR takeover", + "license": "MIT", + "type": "module", + "scripts": { + "dev": "next dev --port 3002", + "build": "next build", + "start": "next start --port 3002", + "clean": "rimraf .next coverage playwright-report test-results .tsbuildinfo", + "preview": "pnpm serve:mocks && pnpm start", + "serve": "pnpm serve:mocks && pnpm serve:app", + "serve:app": "pnpm build && pm2 start --name nextjs-ssr-csr-hybrid-app \"pnpm start\"", + "serve:app:stop": "pm2 stop nextjs-ssr-csr-hybrid-app && pm2 delete nextjs-ssr-csr-hybrid-app", + "serve:mocks": "pm2 start --name nextjs-ssr-csr-hybrid-mocks \"pnpm --dir ../../lib/mocks serve\"", + "serve:mocks:stop": "pm2 stop nextjs-ssr-csr-hybrid-mocks && pm2 delete nextjs-ssr-csr-hybrid-mocks", + "serve:stop": "pnpm serve:app:stop && pnpm serve:mocks:stop", + "test:e2e": "pnpm serve && playwright test; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", + "test:e2e:codegen": "playwright codegen", + "test:e2e:report": "playwright show-report", + "test:e2e:ui": "playwright test --ui", + "implementation:playwright:install": "playwright install", + "implementation:playwright:install-deps": "playwright install-deps", + "implementation:setup:e2e": "pnpm implementation:playwright:install && pnpm implementation:playwright:install-deps", + "test:unit": "echo \"No unit tests necessary\"", + "typecheck": "tsc --noEmit", + "lint": "eslint" + }, + "dependencies": { + "@contentful/optimization-node": "0.0.0", + "@contentful/optimization-react-web": "0.0.0", + "@contentful/optimization-web-preview-panel": "0.0.0", + "@contentful/rich-text-react-renderer": "16.1.6", + "@contentful/rich-text-types": "17.2.5", + "contentful": "11.10.5", + "next": "16.2.4", + "react": "19.2.5", + "react-dom": "19.2.5" + }, + "devDependencies": { + "@playwright/test": "1.58.2", + "@tailwindcss/postcss": "4.1.11", + "@types/node": "24.11.0", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "dotenv": "17.3.1", + "eslint": "9.29.0", + "eslint-config-next": "16.2.4", + "pm2": "6.0.14", + "postcss": "8.5.6", + "rimraf": "6.1.3", + "tailwindcss": "4.1.11", + "typescript": "5.9.3" + }, + "pnpm": { + "overrides": { + "@contentful/optimization-api-client": "file:../../pkgs/contentful-optimization-api-client-0.0.0.tgz", + "@contentful/optimization-api-schemas": "file:../../pkgs/contentful-optimization-api-schemas-0.0.0.tgz", + "@contentful/optimization-core": "file:../../pkgs/contentful-optimization-core-0.0.0.tgz", + "@contentful/optimization-node": "file:../../pkgs/contentful-optimization-node-0.0.0.tgz", + "@contentful/optimization-web": "file:../../pkgs/contentful-optimization-web-0.0.0.tgz", + "@contentful/optimization-react-web": "file:../../pkgs/contentful-optimization-react-web-0.0.0.tgz", + "@contentful/optimization-web-preview-panel": "file:../../pkgs/contentful-optimization-web-preview-panel-0.0.0.tgz" + } + } +} diff --git a/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/pnpm-workspace.yaml b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/pnpm-workspace.yaml new file mode 100644 index 00000000..581a9d5b --- /dev/null +++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +ignoredBuiltDependencies: + - sharp + - unrs-resolver diff --git a/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/postcss.config.mjs b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/postcss.config.mjs new file mode 100644 index 00000000..ae85b2fe --- /dev/null +++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + '@tailwindcss/postcss': {}, + }, +} + +export default config diff --git a/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/tsconfig.json b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/tsconfig.json new file mode 100644 index 00000000..2cc12770 --- /dev/null +++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/tsconfig.json @@ -0,0 +1,38 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "forceConsistentCasingInFileNames": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "**/*.mts" + ], + "exclude": ["node_modules"] +} diff --git a/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/types/contentful.ts b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/types/contentful.ts new file mode 100644 index 00000000..2efb9ad7 --- /dev/null +++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr-csr/types/contentful.ts @@ -0,0 +1,11 @@ +import type { Document } from '@contentful/rich-text-types' +import type { Entry, EntryFieldTypes, EntrySkeletonType } from 'contentful' + +export interface ContentEntryFields { + text?: EntryFieldTypes.Text | EntryFieldTypes.RichText + nested?: EntryFieldTypes.Array> +} + +export type ContentEntrySkeleton = EntrySkeletonType +export type ContentEntry = Entry +export type RichTextDocument = Document diff --git a/package.json b/package.json index 80c50e19..6340b6da 100644 --- a/package.json +++ b/package.json @@ -21,9 +21,10 @@ "implementation:react-web-sdk": "pnpm run implementation:run -- react-web-sdk", "implementation:web-sdk_react": "pnpm run implementation:run -- web-sdk_react", "implementation:react-web-sdk+node-sdk_nextjs-ssr": "pnpm run implementation:run -- react-web-sdk+node-sdk_nextjs-ssr", + "implementation:react-web-sdk+node-sdk_nextjs-ssr-csr": "pnpm run implementation:run -- react-web-sdk+node-sdk_nextjs-ssr-csr", "implementation:web-sdk": "pnpm run implementation:run -- web-sdk", - "implementation:lint": "eslint implementations --ignore-pattern implementations/react-web-sdk+node-sdk_nextjs-ssr --cache --cache-location .cache/eslint/implementations && pnpm run implementation:run -- react-web-sdk+node-sdk_nextjs-ssr lint", - "implementation:lint:fix": "eslint implementations --ignore-pattern implementations/react-web-sdk+node-sdk_nextjs-ssr --fix && pnpm run implementation:run -- react-web-sdk+node-sdk_nextjs-ssr lint", + "implementation:lint": "eslint implementations --ignore-pattern implementations/react-web-sdk+node-sdk_nextjs-ssr --ignore-pattern implementations/react-web-sdk+node-sdk_nextjs-ssr-csr --cache --cache-location .cache/eslint/implementations && pnpm run implementation:run -- react-web-sdk+node-sdk_nextjs-ssr lint", + "implementation:lint:fix": "eslint implementations --ignore-pattern implementations/react-web-sdk+node-sdk_nextjs-ssr --ignore-pattern implementations/react-web-sdk+node-sdk_nextjs-ssr-csr --fix && pnpm run implementation:run -- react-web-sdk+node-sdk_nextjs-ssr lint", "implementation:typecheck": "pnpm run implementation:run -- --all -- typecheck", "lint": "eslint lib packages --cache --cache-location .cache/eslint/workspace", "lint:fix": "eslint lib packages --fix",