Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions examples/nextjs/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,9 @@
SPACE_ID=
ENVIRONMENT_ID=master
CDA_TOKEN=

# Personalization SDK (Milestone 13 spike smoke). NEXT_PUBLIC_* prefix so the
# values are inlined into the client bundle — required because the
# optimization SDK is browser-only.
NEXT_PUBLIC_OPTIMIZATION_CLIENT_ID=
NEXT_PUBLIC_OPTIMIZATION_ENVIRONMENT=main
36 changes: 35 additions & 1 deletion examples/nextjs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,41 @@ cp .env.example .env.local # fill in SPACE_ID + CDA_TOKEN
npm run dev
```

Then visit `http://localhost:3000/<experience-id>` — the slug becomes the Experience ID passed to `client.view.getExperience`.
Then visit `http://localhost:3000/<experience-id>` — the slug becomes the Experience ID passed to `client.view.getExperienceWithOverrides`.

## Personalization (NT-3613 spike smoke)

This example was clobbered as the manual browser smoke for **Milestone 13** of the [optimization spike](../../../optimization/documentation/specs/2026-07-07-exo-optimization-personalization-and-analytics-spike-plan.md). Not intended to be merged as-is.

**What's wired:**

- `lib/delivery-client.ts` now calls `view.getExperienceWithOverrides(...)` with `extensions.sourceMap: {}` so the response carries `extensions.sourceMap` — the per-node personalization provenance table.
- `lib/optimization-client.ts` (new, `'use client'`) constructs a browser-only `ContentfulOptimization` instance keyed off `NEXT_PUBLIC_OPTIMIZATION_CLIENT_ID`. Module-scope singleton so navigation doesn't spin up a second instance.
- `components/ClientExperience.tsx` (new client boundary) mounts `<ClientExperienceRenderer>` with `optimization={{ enabled: true, client: optimizationClient }}`. After hydration, `ClientExperienceRenderer` publishes `OptimizationProvider` to the subtree and calls `attachInteractionRuntime({ views, clicks, hovers })`.
- `app/[slug]/page.tsx` — server component fetches with sourceMap, threads it through `resolveExperience(view, config, { sourceMap })`, and hands the plan to `<ClientExperience>`.

**One-time setup (the optimization peer isn't published to npm):**

```sh
# 1. Build + pack the sibling repo's packages
cd /path/to/contentful/optimization
pnpm build:pkgs

# 2. Back to this workspace — the file: link in package.json points at ../../../optimization/pkgs/*.tgz
cd /path/to/contentful/experiences
npm install
```

**What to look for in the browser:**

- Personalized nodes carry `data-ctfl-node-id` + six more `data-ctfl-*` attrs in the SSR HTML (`view-source`).
- `exo_node_view` fires on scroll into a personalized node (Network → filter `insights`).
- `click` / `hover` fire for personalized nodes only; non-personalized nodes emit nothing.
- `display: contents` on the wrapper does not break flex / grid layout in the fixture.

**What's not wired (deferred from the spike):**

- The `getPersonalizationRequest()` → server POST → `ingestPersonalizationResponse()` round-trip described in spec §4. The SDK is browser-only, so this needs a fetch layer between client and the delivery-client-owning server. Left as follow-up.

## Two routes, same data

Expand Down
13 changes: 9 additions & 4 deletions examples/nextjs/app/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { notFound } from 'next/navigation';
import { ServerExperienceRenderer, resolveExperience } from '@contentful/experiences-react';
import { resolveExperience } from '@contentful/experiences-react';

import { ClientExperience } from '@/components/ClientExperience';
import { fetchExperience } from '@/lib/delivery-client';
import { experienceConfig } from '@/lib/experience-config';

Expand All @@ -11,10 +12,14 @@ interface PageProps {
export default async function ExperiencePage({ params }: PageProps) {
const { slug: experienceId } = await params;

const { payload } = await fetchExperience(experienceId);
// Server-side fetch via `getExperienceWithOverrides` returns the plan
// *and* an `extensions.sourceMap` that describes how each node was picked.
// Threading the sourceMap through `resolveExperience` puts it on the plan
// so per-node instrumentation can light up after hydration.
const { payload, sourceMap } = await fetchExperience(experienceId);
if (!payload.nodes.length) notFound();

const experience = await resolveExperience(payload, experienceConfig);
const experience = await resolveExperience(payload, experienceConfig, { sourceMap });

return <ServerExperienceRenderer experience={experience} config={experienceConfig} />;
return <ClientExperience experience={experience} />;
}
32 changes: 32 additions & 0 deletions examples/nextjs/components/ClientExperience.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
'use client';

import type { ReactNode } from 'react';
import { ClientExperienceRenderer, type PortableRenderPlan } from '@contentful/experiences-react';

import { experienceConfig } from '@/lib/experience-config';
import { getOptimizationClient } from '@/lib/optimization-client';

export interface ClientExperienceProps {
experience: PortableRenderPlan;
}

/**
* Client boundary for the personalization pipeline. The optimization SDK is
* browser-only, so we can't construct it on the server — this component owns
* that lifetime and hands the singleton to `ClientExperienceRenderer` via the
* `optimization` prop. The renderer publishes `OptimizationProvider` to the
* subtree and, after hydration, calls `attachInteractionRuntime({ views,
* clicks, hovers })` — from that point on, personalized nodes emit
* `exo_node_view` / `click` / `hover` events as the user interacts with them.
*/
export function ClientExperience({ experience }: ClientExperienceProps): ReactNode {
const optimization = getOptimizationClient();

return (
<ClientExperienceRenderer
experience={experience}
config={experienceConfig}
optimization={{ enabled: true, client: optimization }}
/>
);
}
49 changes: 34 additions & 15 deletions examples/nextjs/lib/delivery-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,38 +2,57 @@
* Server-side fetch of an Experience via @contentful/experience-delivery.
* Requires SPACE_ID and CDA_TOKEN in the environment.
*
* The delivery client's `GetExperienceViewResponse` is structurally
* compatible with our `ExperiencePayload`, so we hand it straight to
* `buildPlan` without normalization.
* Uses `view.getExperienceWithOverrides` (not `getExperience`) so the response
* carries `extensions.sourceMap` — the personalization SDK's per-node
* provenance table that `resolveExperience` threads onto the plan and the
* adapter consumes at render time. Requesting `extensions.sourceMap: {}` in
* the request body opts the response into carrying the field.
*/

import 'server-only';

import { ContentfulViewDeliveryClient } from '@contentful/experience-delivery';
import type { ExperiencePayload } from '@contentful/experiences-react';
import type {
DeliveryViewSourceMap,
ExperiencePayload,
} from '@contentful/experiences-react';

const experienceClient = new ContentfulViewDeliveryClient({ token: process.env.CDA_TOKEN! });

export interface FetchExperienceResult {
payload: ExperiencePayload;
sourceMap: DeliveryViewSourceMap | undefined;
}

/**
* The delivery client returns a `GetExperienceViewResponse` whose shape is
* structurally compatible with our `ExperiencePayload`. No normalization
* step needed — pass it straight to `buildPlan`.
*/
export async function fetchExperience(
experienceId: string,
options: { locale?: string; preview?: boolean } = {}
options: { locale?: string; preview?: boolean } = {},
): Promise<FetchExperienceResult> {
const spaceId = process.env.SPACE_ID || '';
const environmentId = process.env.ENVIRONMENT_ID ?? 'master';

const response = await experienceClient.view.getExperience(spaceId, environmentId, experienceId, {
locale: options.locale,
preview: options.preview ? 'true' : undefined,
});
const response = await experienceClient.view.getExperienceWithOverrides(
spaceId,
environmentId,
experienceId,
{
locale: options.locale,
preview: options.preview ? 'true' : undefined,
extensions: {
// Opting in to the sourceMap response field. Passing `{}` is enough —
// any value present in the request body is treated as opt-in by XDA.
sourceMap: {},
},
},
);

const { extensions, ...rest } = response;

return { payload: response };
return {
// The overrides response is structurally compatible with our
// `ExperiencePayload` — just strip the `extensions` field so it isn't
// interpreted as node data.
payload: rest as unknown as ExperiencePayload,
sourceMap: extensions?.sourceMap as DeliveryViewSourceMap | undefined,
};
}
61 changes: 19 additions & 42 deletions examples/nextjs/lib/experience-config-advanced.tsx
Original file line number Diff line number Diff line change
@@ -1,83 +1,60 @@
/**
* Advanced integration config — shows the configuration knobs you reach for
* when the simple config (`./experience-config.tsx`) isn't enough.
* Advanced integration config — shows the knobs you reach for when the
* simple config (`./experience-config.tsx`) isn't enough:
*
* Differences from the simple config:
* - **Async `resolveData`** on `button` — a deliberately slow fake fetch,
* plus a synthetic localized URL derived from `experience.metadata.locale`.
* Demonstrates that resolvers run **in parallel across nodes** before
* rendering, and that they have access to `experience.metadata` (which the
* advanced page passes in via `resolveExperience`'s third argument).
* - **Reads `experience.metadata`** to build a per-page label — proves the
* Resolvers run in parallel across nodes before rendering, and they
* receive `experience.metadata` (which the advanced page passes in via
* `resolveExperience`'s third argument).
* - **Reads `experience.metadata`** to build a per-page URL, proving that
* metadata is threaded through every resolver.
*
* Templates and the rest of the components are identical to the simple
* config. Only the `button` is enriched.
*/

import {
defineComponent,
defineTemplate,
type Components,
type Config,
type Templates,
} from '@contentful/experiences-react';

import { Button, type ButtonProps } from '@/components/Button';
import { Header, type HeaderProps } from '@/components/Header';
import { Page, type PageProps } from '@/components/Page';
import { Text, type TextProps } from '@/components/Text';
import { Header } from '@/components/Header';
import { Page } from '@/components/Page';
import { Text } from '@/components/Text';

// Pretend this fetches enrichment from a catalog or pricing API. The point
// is just that it's async and takes non-trivial time — the SDK fans these
// out across all nodes in parallel via Promise.all when resolveExperience is called.
// Pretend this fetches enrichment from a catalog or pricing API. The point is
// just that it's async and takes non-trivial time — the SDK fans these out
// across all nodes in parallel via Promise.all in resolveExperience.
async function fetchButtonEnrichment(text: string): Promise<{ formattedText: string }> {
await new Promise((resolve) => setTimeout(resolve, 50));
return { formattedText: text.toUpperCase() };
}

const components: Components = {
// defineComponent narrows the resolveData ctx + return type to ButtonProps.
button: defineComponent<ButtonProps>({
defaults: { type: 'primary' },
resolveData: async ({ content, experience }) => {
const rawText = (content.text as string) ?? 'Button';
const { formattedText } = await fetchButtonEnrichment(rawText);

// experience.metadata is passed in by the page via
// resolveExperience(payload, config, { experience: { metadata: {...} } })
const locale = (experience.metadata.locale as string) ?? 'en-US';
const slug = (experience.metadata.slug as string) ?? '';

return {
text: formattedText,
url: `/${locale}/${slug}`,
};
},
render: Button,
}),

header: defineComponent<HeaderProps>({
defaults: { variant: 'h2', text: 'Hello World' },
render: Header,
component: Button,
}),

text: defineComponent<TextProps>({
render: Text,
}),
header: { component: Header, defaults: { variant: 'h2', text: 'Hello World' } },
text: Text,
};

const templates: Templates = {
hi: defineTemplate<PageProps>({
defaults: { title: 'Welcome (advanced)' },
render: Page,
}),
hero: defineTemplate<PageProps>({
defaults: { title: 'Featured (advanced)' },
render: Page,
}),
hi: { component: Page, defaults: { title: 'Welcome (advanced)' } },
hero: { component: Page, defaults: { title: 'Featured (advanced)' } },
};

export const advancedExperienceConfig: Config = {
components,
templates,
};
export const advancedExperienceConfig: Config = { components, templates };
83 changes: 21 additions & 62 deletions examples/nextjs/lib/experience-config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,77 +2,36 @@
* The integration layer between Contentful's Experience payload shape and
* the customer's design system.
*
* The design-system components in `../components/` stay free of any
* `@contentful/*` imports — they remain portable and don't know they're
* being driven by Contentful. This file is where SDK-shaped concerns
* (defaults, async resolvers, slot binding, prop reshaping) live.
* Design-system components live in `../components/` and stay free of any
* `@contentful/*` imports. A component that needs the Experience runtime
* context or the raw Contentful payload calls `useExperience()` /
* `useContentfulComponent()` at its top.
*
* - `components` — `componentTypeId` → `defineComponent(...)`. Keys match
* the segment after the last slash in `componentType.sys.urn`.
* - `templates` — `templateId` → `defineTemplate(...)`. Keys match the
* segment after the last slash in `payload.sys.template.sys.urn`.
* - `experienceConfig` — the composed `Config` object handed to
* `resolveExperience` and `<ServerExperienceRenderer>`.
* - `components` — `componentTypeId` → bare component OR config object.
* Keys match the segment after the last slash in `componentType.sys.urn`.
* - `templates` — `templateId` → bare component OR config object. Keys
* match the segment after the last slash in `payload.sys.template.sys.urn`.
*/

import type { ReactNode } from 'react';
import { type Components, type Config, type Templates } from '@contentful/experiences-react';

import {
defineComponent,
defineTemplate,
type Components,
type Config,
type Templates,
} from '@contentful/experiences-react';

import { Button, type ButtonProps } from '@/components/Button';
import { Header, type HeaderProps } from '@/components/Header';
import { Page, type PageProps } from '@/components/Page';
import { Text, type TextProps } from '@/components/Text';
import { Button } from '@/components/Button';
import { Header } from '@/components/Header';
import { Page } from '@/components/Page';
import { Text } from '@/components/Text';

const components: Components = {
button: defineComponent<ButtonProps>({
defaults: { type: 'primary' },
resolveData: async ({ content, design, experience }) => {
// server side rendering for the contentful design system
// console.log("design", design);

// add expensive thing here and an async call
return {
text: content.text as string,
children: content.testSlot as ReactNode,
design: design,
experience: experience,
};
},
render: (props) => {
// client side rendering for the contentful design system
return <Button {...props} />;
},
}),
// Bare-component form — for the common case with no defaults / resolveData.
button: Button,
text: Text,

header: defineComponent<HeaderProps>({
defaults: { variant: 'h2', text: 'Hello World' },
render: Header,
}),

text: defineComponent<TextProps>({
render: Text,
}),
// Config-object form — when you need defaults or resolveData.
header: { component: Header, defaults: { variant: 'h2', text: 'Hello World' } },
};

const templates: Templates = {
hi: defineTemplate<PageProps>({
defaults: { title: 'Welcome' },
render: Page,
}),
hero: defineTemplate<PageProps>({
defaults: { title: 'Featured' },
render: Page,
}),
hi: { component: Page, defaults: { title: 'Welcome' } },
hero: { component: Page, defaults: { title: 'Featured' } },
};

export const experienceConfig: Config = {
components,
templates,
};
export const experienceConfig: Config = { components, templates };
Loading
Loading