diff --git a/implementations/react-web-sdk+node-sdk_nextjs-ssr/.env.example b/implementations/react-web-sdk+node-sdk_nextjs-ssr/.env.example new file mode 100644 index 00000000..9273d7d6 --- /dev/null +++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr/.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/.gitignore b/implementations/react-web-sdk+node-sdk_nextjs-ssr/.gitignore new file mode 100644 index 00000000..818f88a7 --- /dev/null +++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr/.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/.npmrc b/implementations/react-web-sdk+node-sdk_nextjs-ssr/.npmrc new file mode 100644 index 00000000..135f7a0d --- /dev/null +++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr/.npmrc @@ -0,0 +1 @@ +shared-workspace-lockfile=false diff --git a/implementations/react-web-sdk+node-sdk_nextjs-ssr/AGENTS.md b/implementations/react-web-sdk+node-sdk_nextjs-ssr/AGENTS.md new file mode 100644 index 00000000..436a41b1 --- /dev/null +++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr/AGENTS.md @@ -0,0 +1,45 @@ +# AGENTS.md + +Read the repository root `AGENTS.md` first. + +## Scope + +This is the Next.js SSR hybrid reference implementation. The Node SDK resolves entries on the server +(personalization is SSR). The React SDK hydrates on the client for event tracking and interactive +controls (consent, identify, page views, clicks). + +This represents a customer setup where: + +- Personalized content is resolved server-side for fast first paint +- Client-side JS is only used for tracking and interactive features +- The same anonymous profile cookie bridges server and client + +## Key Paths + +- `app/` — Next.js App Router (single page, Server Component) +- `lib/` — SDK config, Contentful client, Node SDK singleton +- `components/` — ClientProviderWrapper (React SDK), InteractiveControls +- `middleware.ts` — cookie lifecycle +- `.env.example` + +## Local Rules + +- Next.js App Router only. No Pages Router. +- Server Components must not import from `@contentful/optimization-react-web`. +- Client components (`"use client"`) must not import from `@contentful/optimization-node`. +- 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 implementation:install` +- `pnpm implementation:run -- react-web-sdk+node-sdk_nextjs-ssr typecheck` +- `pnpm implementation:run -- react-web-sdk+node-sdk_nextjs-ssr build` +- `pnpm implementation:run -- react-web-sdk+node-sdk_nextjs-ssr dev` +- `pnpm implementation:run -- react-web-sdk+node-sdk_nextjs-ssr serve` +- `pnpm implementation:run -- react-web-sdk+node-sdk_nextjs-ssr 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/README.md b/implementations/react-web-sdk+node-sdk_nextjs-ssr/README.md new file mode 100644 index 00000000..dc59a3ff --- /dev/null +++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr/README.md @@ -0,0 +1,192 @@ +# Next.js SSR + Client Events Reference Implementation + +`react-web-sdk+node-sdk_nextjs-ssr` — Next.js App Router reference using +`@contentful/optimization-node` for server-side entry resolution and +`@contentful/optimization-react-web` for client-side event tracking and interactive controls. + +## Pattern: SSR-Primary with CSR Analytics + +This setup is the simplest and most robust Next.js personalization pattern. The server owns all +personalization decisions. The client owns all analytics and interactive concerns. + +### Why this pattern? + +- **No flicker.** Personalized content is in the HTML from the server. No loading states, no + client-side variant swaps. +- **Full SEO.** Search engines see the resolved personalized content. +- **Minimal client JS.** Content rendering requires zero JavaScript. Only tracking and interactive + controls (consent, identify) need client hydration. +- **No Next.js SDK needed.** The Node SDK (stateless) works in Server Components and Middleware. The + React Web SDK (stateful) works in Client Components. No framework-specific glue package required. + +### Responsibility split + +| Concern | Where it runs | SDK used | +| ------------------------------------------------ | ------------------------- | ------------------------------------------- | +| Anonymous ID cookie lifecycle | Middleware (Edge Runtime) | Node SDK | +| Profile resolution (`sdk.page()`) | Server Component | Node SDK | +| Entry variant resolution | Server Component | Node SDK (`resolveOptimizedEntry`) | +| HTML rendering of personalized content | Server Component | None (plain React) | +| Page view tracking | Client (after hydration) | React Web SDK (`NextAppAutoPageTracker`) | +| Entry interaction tracking (views/clicks/hovers) | 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()`) | + +### Behavioral expectations + +Once the page is served, the personalized content is **static until the next server roundtrip**. +Client-side actions like granting consent or identifying the user update the Web SDK's internal +state and fire analytics events, but they do **not** cause the page content to re-render or swap +variants. The user sees the updated personalization only on the next full navigation (a new server +request where the Node SDK re-resolves entries with the updated profile). + +This is intentional: the server is the single source of truth for what content to show. The client +never contradicts what the server rendered. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ REQUEST PHASE (Server) │ +│ │ +│ 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 (page.tsx) │ +│ ├─ Read `ctfl-opt-aid` cookie (set by middleware in same cycle) │ +│ ├─ Fetch Contentful entries from CDA (in parallel) │ +│ ├─ Call Node SDK `sdk.page()` → get selectedOptimizations │ +│ ├─ For each entry: `sdk.resolveOptimizedEntry(entry, selected)` │ +│ └─ Render resolved entries as plain HTML │ +│ │ +│ ↓ HTML response with personalized content │ +└─────────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────────┐ +│ HYDRATION PHASE (Browser) │ +│ │ +│ 3. ClientProviderWrapper (dynamic, ssr: false) │ +│ ├─ OptimizationRoot initializes Web SDK │ +│ ├─ Reads `ctfl-opt-aid` cookie → same identity as server │ +│ ├─ NextAppAutoPageTracker fires page view event │ +│ └─ autoTrackEntryInteraction observes elements with │ +│ data-ctfl-entry-id attributes (views, clicks, hovers) │ +│ │ +│ 4. InteractiveControls (client component) │ +│ ├─ Subscribes to sdk.states.consent / sdk.states.profile │ +│ ├─ Renders consent toggle button │ +│ └─ Renders identify / reset buttons │ +│ │ +│ Note: No content re-rendering happens client-side. │ +│ Content remains as server-rendered until next navigation. │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +## Key implementation patterns + +### 1. Cookie as the identity bridge + +The `ctfl-opt-aid` cookie is the **only shared state** between server and client. Middleware creates +it, the Server Component reads it, and the Web SDK reads it from `document.cookie` after hydration. +This ensures both sides operate on the same anonymous profile. + +```typescript +// middleware.ts +const anonymousId = request.cookies.get(ANONYMOUS_ID_COOKIE)?.value +const profile = anonymousId ? { id: anonymousId } : undefined +const data = await sdk.page({ ...requestContext, profile }) +response.cookies.set(ANONYMOUS_ID_COOKIE, data.profile.id, { path: '/', sameSite: 'lax' }) +``` + +### 2. Node SDK as a module-level singleton + +The Node SDK is stateless and safe to reuse across requests. A single instance is created at module +load and imported by both middleware and page: + +```typescript +// lib/optimization-server.ts +import ContentfulOptimization from '@contentful/optimization-node' +const sdk = new ContentfulOptimization({ clientId, environment, api }) +export { sdk } +``` + +### 3. React Web SDK loaded only on the client + +The Web SDK depends on browser APIs (`localStorage`, `document.cookie`, `IntersectionObserver`). +Using `next/dynamic` with `ssr: false` prevents any server-side instantiation: + +```typescript +// components/ClientProviderWrapper.tsx +const OptimizationRoot = dynamic( + () => + import('@contentful/optimization-react-web').then((mod) => ({ default: mod.OptimizationRoot })), + { ssr: false }, +) +``` + +### 4. Server Components never import from the React Web SDK + +This is a hard rule. Server Components use `@contentful/optimization-node` only. Client Components +(`"use client"`) use `@contentful/optimization-react-web` only. Mixing them causes runtime errors or +bundling issues. + +### 5. Data attributes for automatic interaction tracking + +Server-rendered entries include `data-ctfl-entry-id` and `data-ctfl-baseline-id` attributes. After +hydration, the Web SDK's `autoTrackEntryInteraction` uses a MutationObserver to detect these +elements and registers IntersectionObserver (views), click listeners, and hover listeners +automatically: + +```tsx +
+ {/* content */} +
+``` + +## When does the user see updated personalization? + +| User action | Effect on displayed content | When personalization updates | +| ------------------------------------------ | --------------------------------------- | ---------------------------- | +| First page load (anonymous) | Baseline or variant per profile | Immediate (server-resolved) | +| Grant/reject consent | No change to content | Next server request | +| Identify (`sdk.identify()`) | No change to content | Next server request | +| Navigate to another page (full navigation) | New server-resolved content | Immediate (new SSR) | +| Browser refresh | Server re-resolves with updated profile | Immediate (new SSR) | + +The key insight: **client actions update the profile server-side (via the Experience API)**, but the +rendered content is only a snapshot of the profile state at the time of the server request. The next +request will reflect the updated profile. + +## Setup + +```bash +pnpm build:pkgs +pnpm implementation:run -- react-web-sdk+node-sdk_nextjs-ssr implementation:install +cp implementations/react-web-sdk+node-sdk_nextjs-ssr/.env.example implementations/react-web-sdk+node-sdk_nextjs-ssr/.env +``` + +## Development + +```bash +pnpm implementation:run -- react-web-sdk+node-sdk_nextjs-ssr dev +``` + +## When to use this pattern + +- Content-heavy marketing sites where SEO and first-paint performance matter +- Sites where personalization is based on profile traits, audience segments, or geo — not real-time + interactions within the same page +- Teams that want the simplest mental model: server decides what to show, client tracks what + happened +- Sites already using Next.js App Router with Server Components + +## When NOT to use this pattern + +- If you need instant client-side variant swaps after identify (e.g., "Welcome back, Charles!" + appearing without a page refresh) — consider (Hybrid SSR + CSR takeover) +- If your site is a pure SPA with no server rendering — use the React Web SDK directly (see + `react-web-sdk` implementation) +- If you need edge-side personalization for static/cached pages — consider a middleware-based ESR + pattern with `resolveOptimizedEntry` at the edge diff --git a/implementations/react-web-sdk+node-sdk_nextjs-ssr/app/globals.css b/implementations/react-web-sdk+node-sdk_nextjs-ssr/app/globals.css new file mode 100644 index 00000000..d4b50785 --- /dev/null +++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr/app/globals.css @@ -0,0 +1 @@ +@import 'tailwindcss'; diff --git a/implementations/react-web-sdk+node-sdk_nextjs-ssr/app/layout.tsx b/implementations/react-web-sdk+node-sdk_nextjs-ssr/app/layout.tsx new file mode 100644 index 00000000..3f6c44b9 --- /dev/null +++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr/app/layout.tsx @@ -0,0 +1,23 @@ +import { ClientProviderWrapper } from '@/components/ClientProviderWrapper' +import type { Metadata } from 'next' +import './globals.css' + +export const metadata: Metadata = { + title: 'Optimization Next.js SSR Hybrid', + description: + 'Next.js App Router reference: Node SDK resolves entries server-side, React SDK handles client-side tracking and interactive controls.', +} + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( + + + {children} + + + ) +} diff --git a/implementations/react-web-sdk+node-sdk_nextjs-ssr/app/page.tsx b/implementations/react-web-sdk+node-sdk_nextjs-ssr/app/page.tsx new file mode 100644 index 00000000..b4adc6af --- /dev/null +++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr/app/page.tsx @@ -0,0 +1,86 @@ +import { InteractiveControls } from '@/components/InteractiveControls' +import { ENTRY_IDS } from '@/config/entries' +import { fetchEntries } from '@/lib/contentful-client' +import { sdk } from '@/lib/optimization-server' +import type { ContentEntry } from '@/types/contentful' +import { ANONYMOUS_ID_COOKIE } from '@contentful/optimization-node/constants' +import { cookies, headers } from 'next/headers' + +function getEntryText(entry: ContentEntry): string { + return typeof entry.fields.text === 'string' ? entry.fields.text : 'No content' +} + +function ServerRenderedEntry({ + baselineEntry, + resolvedEntry, +}: { + baselineEntry: ContentEntry + resolvedEntry: ContentEntry +}) { + return ( +
+

{getEntryText(resolvedEntry)}

+

{`[Entry: ${baselineEntry.sys.id}]`}

+
+ ) +} + +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([ + fetchEntries(ENTRY_IDS), + 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 ( +
+

Next.js SSR Hybrid

+

+ Entries are resolved on the server via the Node SDK. The HTML contains personalized content + with zero client-side JavaScript for rendering. The React SDK hydrates for event tracking + and interactive controls (consent, identify). +

+ + + +
+

Entries (Server-Resolved)

+ {baselineEntries.length === 0 ? ( +

No entries found.

+ ) : ( +
+ {baselineEntries.map((entry, index) => ( + + ))} +
+ )} +
+
+ ) +} diff --git a/implementations/react-web-sdk+node-sdk_nextjs-ssr/components/ClientProviderWrapper.tsx b/implementations/react-web-sdk+node-sdk_nextjs-ssr/components/ClientProviderWrapper.tsx new file mode 100644 index 00000000..d5158bce --- /dev/null +++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr/components/ClientProviderWrapper.tsx @@ -0,0 +1,52 @@ +'use client' + +// The Web SDK requires browser APIs (localStorage, document.cookie) and cannot +// be instantiated during SSR. Using next/dynamic with ssr:false ensures +// OptimizationRoot is only loaded and rendered on the client. Without this, +// child components that call useOptimization() would throw when rendered +// outside the provider during server rendering. + +import { optimizationConfig } from '@/lib/config' +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 +} + +export function ClientProviderWrapper({ children }: ClientProviderWrapperProps) { + return ( + + + + + {children} + + ) +} diff --git a/implementations/react-web-sdk+node-sdk_nextjs-ssr/components/InteractiveControls.tsx b/implementations/react-web-sdk+node-sdk_nextjs-ssr/components/InteractiveControls.tsx new file mode 100644 index 00000000..d6f1c4f3 --- /dev/null +++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr/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/config/entries.ts b/implementations/react-web-sdk+node-sdk_nextjs-ssr/config/entries.ts new file mode 100644 index 00000000..72962fff --- /dev/null +++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr/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/eslint.config.mjs b/implementations/react-web-sdk+node-sdk_nextjs-ssr/eslint.config.mjs new file mode 100644 index 00000000..694bcdc6 --- /dev/null +++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr/eslint.config.mjs @@ -0,0 +1,20 @@ +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, + globalIgnores(['.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/lib/config.ts b/implementations/react-web-sdk+node-sdk_nextjs-ssr/lib/config.ts new file mode 100644 index 00000000..5abfe222 --- /dev/null +++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr/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/lib/contentful-client.ts b/implementations/react-web-sdk+node-sdk_nextjs-ssr/lib/contentful-client.ts new file mode 100644 index 00000000..aa0b99c4 --- /dev/null +++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr/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/lib/optimization-server.ts b/implementations/react-web-sdk+node-sdk_nextjs-ssr/lib/optimization-server.ts new file mode 100644 index 00000000..5a48f116 --- /dev/null +++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr/lib/optimization-server.ts @@ -0,0 +1,15 @@ +import ContentfulOptimization from '@contentful/optimization-node' +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 Hybrid (Server)', + version: '0.1.0', + }, +}) + +export { sdk } diff --git a/implementations/react-web-sdk+node-sdk_nextjs-ssr/middleware.ts b/implementations/react-web-sdk+node-sdk_nextjs-ssr/middleware.ts new file mode 100644 index 00000000..3e37b251 --- /dev/null +++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr/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/next.config.ts b/implementations/react-web-sdk+node-sdk_nextjs-ssr/next.config.ts new file mode 100644 index 00000000..9333b88a --- /dev/null +++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr/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/package.json b/implementations/react-web-sdk+node-sdk_nextjs-ssr/package.json new file mode 100644 index 00000000..9dbea176 --- /dev/null +++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr/package.json @@ -0,0 +1,68 @@ +{ + "name": "@implementation/react-web-sdk+node-sdk_nextjs-ssr", + "private": true, + "version": "0.0.0", + "description": "Reference implementation for Next.js (App Router) using @contentful/optimization-node for SSR entry resolution and @contentful/optimization-react-web for client-side event tracking", + "license": "MIT", + "type": "module", + "scripts": { + "dev": "next dev --port 3001", + "build": "next build", + "start": "next start --port 3001", + "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-hybrid-app \"pnpm start\"", + "serve:app:stop": "pm2 stop nextjs-ssr-hybrid-app && pm2 delete nextjs-ssr-hybrid-app", + "serve:mocks": "pm2 start --name nextjs-ssr-hybrid-mocks \"pnpm --dir ../../lib/mocks serve\"", + "serve:mocks:stop": "pm2 stop nextjs-ssr-hybrid-mocks && pm2 delete nextjs-ssr-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/pnpm-workspace.yaml b/implementations/react-web-sdk+node-sdk_nextjs-ssr/pnpm-workspace.yaml new file mode 100644 index 00000000..581a9d5b --- /dev/null +++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +ignoredBuiltDependencies: + - sharp + - unrs-resolver diff --git a/implementations/react-web-sdk+node-sdk_nextjs-ssr/postcss.config.mjs b/implementations/react-web-sdk+node-sdk_nextjs-ssr/postcss.config.mjs new file mode 100644 index 00000000..ae85b2fe --- /dev/null +++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr/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/tsconfig.json b/implementations/react-web-sdk+node-sdk_nextjs-ssr/tsconfig.json new file mode 100644 index 00000000..2cc12770 --- /dev/null +++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr/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/types/contentful.ts b/implementations/react-web-sdk+node-sdk_nextjs-ssr/types/contentful.ts new file mode 100644 index 00000000..2efb9ad7 --- /dev/null +++ b/implementations/react-web-sdk+node-sdk_nextjs-ssr/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 a3802b29..80c50e19 100644 --- a/package.json +++ b/package.json @@ -20,9 +20,10 @@ "implementation:run": "tsx ./scripts/run-implementation-script.ts", "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:web-sdk": "pnpm run implementation:run -- web-sdk", - "implementation:lint": "eslint implementations --cache --cache-location .cache/eslint/implementations", - "implementation:lint:fix": "eslint implementations --fix", + "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:typecheck": "pnpm run implementation:run -- --all -- typecheck", "lint": "eslint lib packages --cache --cache-location .cache/eslint/workspace", "lint:fix": "eslint lib packages --fix",