Contentful Personalization & Analytics
    Preparing search index...

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

    Table of Contents

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

    pnpm add @contentful/optimization-node @contentful/optimization-react-web
    

    Create the SDK once at module level. It is stateless and safe to share across all requests.

    // lib/optimization-server.ts
    import ContentfulOptimization from '@contentful/optimization-node'

    const sdk = new ContentfulOptimization({
    clientId: process.env.CONTENTFUL_OPTIMIZATION_CLIENT_ID ?? '',
    environment: process.env.CONTENTFUL_OPTIMIZATION_ENVIRONMENT ?? 'main',
    api: {
    experienceBaseUrl: process.env.CONTENTFUL_EXPERIENCE_API_BASE_URL,
    insightsBaseUrl: process.env.CONTENTFUL_INSIGHTS_API_BASE_URL,
    },
    app: {
    name: 'my-next-app',
    version: '1.0.0',
    },
    logLevel: 'error',
    })

    export { sdk }

    Do not create a new instance per request. The Node SDK is designed to be a process-level singleton. Pass request-scoped context (locale, user agent, profile, page URL) as arguments to each method call.

    Next.js middleware runs on the Edge Runtime before every request reaches a Server Component. Use it to ensure the anonymous ID cookie exists and is populated before the Server Component tries to read it.

    // middleware.ts
    import { sdk } from '@/lib/optimization-server'
    import { ANONYMOUS_ID_COOKIE } from '@contentful/optimization-node/constants'
    import { type NextRequest, NextResponse } from 'next/server'

    export async function middleware(request: NextRequest): Promise<NextResponse> {
    const anonymousId = request.cookies.get(ANONYMOUS_ID_COOKIE)?.value
    const profile = anonymousId ? { id: anonymousId } : undefined

    const url = new URL(request.url)
    const data = await sdk.page({
    locale: request.headers.get('accept-language')?.split(',')[0] ?? 'en-US',
    userAgent: request.headers.get('user-agent') ?? 'next-js-server',
    page: {
    path: url.pathname,
    query: Object.fromEntries(url.searchParams),
    referrer: request.headers.get('referer') ?? '',
    search: url.search,
    url: request.url,
    },
    profile,
    })

    const response = NextResponse.next()

    if (data.profile.id) {
    response.cookies.set(ANONYMOUS_ID_COOKIE, data.profile.id, {
    path: '/',
    sameSite: 'lax',
    })
    }

    return response
    }

    export const config = {
    matcher: ['/((?!_next/static|_next/image|favicon.ico|api).*)'],
    }

    The ANONYMOUS_ID_COOKIE constant is the shared cookie name used by both the Node SDK and the React Web SDK. Using the same name means that after hydration, the Web SDK reads the same anonymous ID from document.cookie and continues the same profile journey the server started.

    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.

    Inside a Server Component, read the cookie, call sdk.page() in parallel with your Contentful fetch, then resolve each entry:

    // app/page.tsx
    import { sdk } from '@/lib/optimization-server'
    import { ANONYMOUS_ID_COOKIE } from '@contentful/optimization-node/constants'
    import { cookies, headers } from 'next/headers'

    export default async function Home() {
    const cookieStore = await cookies()
    const headerStore = await headers()

    const anonymousId = cookieStore.get(ANONYMOUS_ID_COOKIE)?.value
    const profile = anonymousId ? { id: anonymousId } : undefined

    const [baselineEntries, optimizationData] = await Promise.all([
    fetchEntriesFromContentful(),
    sdk.page({
    locale: headerStore.get('accept-language')?.split(',')[0] ?? 'en-US',
    userAgent: headerStore.get('user-agent') ?? 'next-js-server',
    profile,
    }),
    ])

    const resolvedEntries = baselineEntries.map((entry) => {
    const { entry: resolved } = sdk.resolveOptimizedEntry(
    entry,
    optimizationData.selectedOptimizations,
    )
    return resolved
    })

    return (
    <main>
    {resolvedEntries.map((entry) => (
    <EntryCard key={entry.sys.id} entry={entry} />
    ))}
    </main>
    )
    }

    Fetch Contentful entries with include: 10 so that linked optimization data (such as nt_experiences) is included in the response. The Node SDK needs those nested fields to evaluate variants.

    resolveOptimizedEntry is synchronous. It picks the correct variant from the resolved entry based on selectedOptimizations returned by sdk.page(). If no optimization applies, it returns the baseline entry unchanged.

    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:

    function ServerRenderedEntry({
    baselineEntry,
    resolvedEntry,
    }: {
    baselineEntry: ContentEntry
    resolvedEntry: ContentEntry
    }) {
    return (
    <div data-ctfl-entry-id={resolvedEntry.sys.id} data-ctfl-baseline-id={baselineEntry.sys.id}>
    <p>{resolvedEntry.fields.title}</p>
    </div>
    )
    }

    data-ctfl-entry-id is the resolved (possibly variant) entry ID. data-ctfl-baseline-id is the original baseline entry ID. Both are required for the client SDK to associate interaction events with the correct optimization context.

    The React Web SDK depends on browser APIs (localStorage, document.cookie, IntersectionObserver). These APIs are not available during server rendering in Next.js. Importing the package in a Server Component causes a runtime error.

    Use next/dynamic with ssr: false to prevent the SDK from loading during server rendering:

    // components/ClientProviderWrapper.tsx
    'use client'

    import dynamic from 'next/dynamic'
    import { Suspense, type ReactNode } from 'react'

    const OptimizationRoot = dynamic(
    () =>
    import('@contentful/optimization-react-web').then((mod) => ({
    default: mod.OptimizationRoot,
    })),
    { ssr: false },
    )

    The 'use client' directive is required. next/dynamic is a Client Component feature, and the import of @contentful/optimization-react-web must not reach Server Component module graph resolution.

    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.

    Mount NextAppAutoPageTracker inside OptimizationRoot to emit page() events automatically whenever the App Router pathname changes:

    const NextAppAutoPageTracker = dynamic(
    () =>
    import('@contentful/optimization-react-web/router/next-app').then((mod) => ({
    default: mod.NextAppAutoPageTracker,
    })),
    { ssr: false },
    )

    Wrap the tracker in <Suspense> to prevent it from blocking the initial render.

    Assemble the client wrapper component and use it in app/layout.tsx:

    // components/ClientProviderWrapper.tsx
    'use client'

    import dynamic from 'next/dynamic'
    import { Suspense, type ReactNode } from 'react'

    const OptimizationRoot = dynamic(
    () =>
    import('@contentful/optimization-react-web').then((mod) => ({
    default: mod.OptimizationRoot,
    })),
    { ssr: false },
    )

    const NextAppAutoPageTracker = dynamic(
    () =>
    import('@contentful/optimization-react-web/router/next-app').then((mod) => ({
    default: mod.NextAppAutoPageTracker,
    })),
    { ssr: false },
    )

    export function ClientProviderWrapper({ children }: { children: ReactNode }) {
    return (
    <OptimizationRoot
    clientId={process.env.NEXT_PUBLIC_OPTIMIZATION_CLIENT_ID ?? ''}
    environment={process.env.NEXT_PUBLIC_OPTIMIZATION_ENVIRONMENT ?? 'main'}
    autoTrackEntryInteraction={{ views: true, clicks: true, hovers: true }}
    logLevel="error"
    >
    <Suspense>
    <NextAppAutoPageTracker />
    </Suspense>
    {children}
    </OptimizationRoot>
    )
    }
    // app/layout.tsx
    import { ClientProviderWrapper } from '@/components/ClientProviderWrapper'

    export default function RootLayout({ children }: { children: React.ReactNode }) {
    return (
    <html lang="en">
    <body>
    <ClientProviderWrapper>{children}</ClientProviderWrapper>
    </body>
    </html>
    )
    }

    layout.tsx itself is a Server Component. ClientProviderWrapper is a Client Component, which is correct — React's composition model allows a Server Component to render a Client Component as a child.

    Environment variables exposed to client code in Next.js must use the NEXT_PUBLIC_ prefix. The Node SDK on the server reads variables without that prefix. Keep them separate in your .env file:

    # Used by the Node SDK (server only)
    CONTENTFUL_OPTIMIZATION_CLIENT_ID="your-client-id"
    CONTENTFUL_OPTIMIZATION_ENVIRONMENT="main"

    # Used by the React Web SDK (exposed to the browser)
    NEXT_PUBLIC_OPTIMIZATION_CLIENT_ID="your-client-id"
    NEXT_PUBLIC_OPTIMIZATION_ENVIRONMENT="main"

    Consent and identify are client-side concerns. Create a Client Component that subscribes to the SDK state and exposes the controls:

    // components/InteractiveControls.tsx
    'use client'

    import { useOptimizationContext } from '@contentful/optimization-react-web'
    import { useEffect, useState } from 'react'

    export function InteractiveControls() {
    const { sdk, isReady } = useOptimizationContext()
    const [consent, setConsent] = useState<boolean | undefined>(undefined)

    useEffect(() => {
    if (!sdk || !isReady) return

    const sub = sdk.states.consent.subscribe(setConsent)

    return () => sub.unsubscribe()
    }, [sdk, isReady])

    if (!sdk || !isReady) return null

    return (
    <div>
    <button onClick={() => sdk.consent(consent !== true)}>
    {consent === true ? 'Reject consent' : 'Accept consent'}
    </button>
    <button onClick={() => sdk.identify({ userId: 'user-123' })}>Identify</button>
    <button onClick={() => sdk.reset()}>Reset profile</button>
    </div>
    )
    }

    InteractiveControls can be mounted inside a Server Component page — React allows Client Components to be children of Server Components.

    Client actions update the Optimization profile via the Experience API, but they do not re-render the server-resolved content on the current page. The updated profile is reflected on the next server request (navigation or browser refresh).

    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.

    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.

    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.