# Implementation Examples
Real-world implementation patterns for Contentful Personalization with Next.js, Contentful, and the
SDK.
Use `@contentful/optimization` for new integrations and load the matching runtime-specific
`optimization-*.md` references for authoritative code. The numbered Ninetailed sections remain for
diagnosing, repairing, or extending repositories that already use the legacy SDK.
---
## Recommended: `@contentful/optimization`
Verify package versions from the target project's lockfile before copying code.
### Provider setup (React)
```tsx
import { OptimizationRoot } from '@contentful/optimization-react-web';
import { ReactRouterAutoPageTracker } from '@contentful/optimization-react-web/router/react-router';
export function App() {
return (
{/* ... */}
);
}
```
### Next.js App Router (adapter)
Use `createNextjsAppRouterOptimization` from `@contentful/optimization-nextjs/app-router` and
consume the bound root, entry, tracker, and request handler from one application module. For Pages
Router, use the separate `/pages-router` and `/pages-router/server` factories. Do not copy a generic
`/client` plus `/server` composition. Load the matching Next.js runtime reference for the canonical
topology.
### OptimizedEntry (client render prop)
```tsx
import { OptimizedEntry } from '@contentful/optimization-react-web';
function HeroEntry({ baselineEntry }) {
return (
{(resolvedEntry) => }
);
}
```
`baselineEntry` must include `nt_experiences` (fetch with `include: 10`). The Optimization SDK resolves the
same `nt_experiences` / `nt_variants` content model as the legacy SDK.
### Actions, state, and flags (hooks)
```tsx
import { useOptimization, useOptimizationActions, useProfileState } from '@contentful/optimization-react-web';
function Cta() {
const { trackEvent } = useOptimizationActions(); // destructurable actions
return ;
}
function Debug() {
const optimization = useOptimization(); // SDK instance — do NOT destructure
const profile = useProfileState();
const flag = optimization.getFlag('dark-mode');
return
{JSON.stringify({ profile, flag }, null, 2)}
;
}
```
### Data fetching (shared with the legacy patterns)
Fetch a single-locale CDA entry with deep includes so `nt_experiences` and their variants resolve:
```ts
const entries = await client.getEntries({
content_type: 'page',
'fields.slug': slug,
include: 10,
limit: 1,
});
```
Do **not** pass all-locale (`withAllLocales` / `locale=*`) responses to `OptimizedEntry`,
`resolveOptimizedEntry()`, or `useEntryResolver()` — they expect direct single-locale field values.
---
## Existing legacy deployments: `@ninetailed/experience.js`
The remaining patterns apply only when maintaining a repository that already uses this SDK.
## Table of Contents
1. [Pages Router Provider Setup](#1-pages-router-provider-setup)
2. [App Router Provider Setup](#2-app-router-provider-setup)
3. [BlockRenderer and ComponentTypeMap Pattern](#3-blockrenderer-and-contenttypemap-pattern)
4. [Contentful Client Configuration](#4-contentful-client-configuration)
5. [Data Fetching Patterns](#5-data-fetching-patterns)
6. [Experience Component Usage](#6-experience-component-usage)
7. [Personalize Component Usage](#7-personalize-component-usage)
8. [ExperienceMapper Usage](#8-experiencemapper-usage)
9. [Hook Patterns](#9-hook-patterns)
10. [Feature Flag Patterns](#10-feature-flag-patterns)
11. [ISR and Revalidation](#11-isr-and-revalidation)
12. [ESR Provider Pattern](#12-esr-provider-pattern)
13. [Environment Variables](#13-environment-variables)
14. [Error Handling Patterns](#14-error-handling-patterns)
---
## 1. Pages Router Provider Setup
### Simple Setup (with SSR and Preview plugins)
```typescript
import { NinetailedProvider } from '@ninetailed/experience.js-next';
import { NinetailedPreviewPlugin } from '@ninetailed/experience.js-plugin-preview';
import { NinetailedSsrPlugin } from '@ninetailed/experience.js-plugin-ssr';
function CustomApp({ Component, pageProps }: AppProps) {
return (
{
console.log('caught error');
}}
>
);
}
```
Key details:
- The `onError` callback fires when the Ninetailed API is unavailable or returns an error.
- `pageProps.ninetailed?.preview.allExperiences` is populated by `getStaticProps`.
- The SSR plugin persists the anonymous ID in a cookie for server-side rendering.
### Full-Featured Setup (with Insights and Preview plugins)
```typescript
import { ExperienceConfiguration, NinetailedProvider } from '@ninetailed/experience.js-next';
import { NinetailedPreviewPlugin } from '@ninetailed/experience.js-plugin-preview';
import { NinetailedInsightsPlugin } from '@ninetailed/experience.js-plugin-insights';
import type { ExposedAudienceDefinition } from '@ninetailed/experience.js-preview-bridge';
interface CustomPageProps {
page: IPage;
ninetailed?: {
experiments: ExperienceConfiguration[];
preview: {
experiences: ExperienceConfiguration[];
audiences: ExposedAudienceDefinition[];
};
};
}
const MyApp = ({ Component, pageProps }: AppProps) => {
return (
{
console.log({ experience });
},
onOpenAudienceEditor: (audience) => {
console.log({ audience });
},
}),
new NinetailedInsightsPlugin(),
]}
clientId={process.env.NEXT_PUBLIC_NINETAILED_CLIENT_ID ?? ''}
environment={process.env.NEXT_PUBLIC_NINETAILED_ENVIRONMENT ?? 'main'}
>
);
};
```
Key differences:
- Uses `NinetailedInsightsPlugin` (analytics) instead of `NinetailedSsrPlugin`
- Preview plugin configured with `nonce` for CSP support
- Typed `CustomPageProps` interface
---
## 2. App Router Provider Setup
For App Router, the `NinetailedProvider` from `@ninetailed/experience.js-next` does NOT auto-track page views (that is a Pages Router feature). You must handle page tracking manually in that legacy deployment. New integrations should use the Optimization SDK's router tracker.
With the recommended SDK (`@contentful/optimization-react-web`):
```tsx
import { OptimizationRoot } from '@contentful/optimization-react-web';
import { NextAppAutoPageTracker } from '@contentful/optimization-react-web/router/next-app';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
{children}
);
}
```
With the legacy SDK, wrap in a client component:
```tsx
'use client';
import { NinetailedProvider } from '@ninetailed/experience.js-react';
export function Providers({ children, ninetailed }: ProvidersProps) {
return (
{children}
);
}
```
---
## 3. BlockRenderer and ComponentTypeMap Pattern
The BlockRenderer is the canonical pattern for rendering Contentful entries as React components with experience support.
### Content Type Constants
```typescript
export const ComponentContentTypes = {
Hero: 'hero',
CTA: 'cta',
Feature: 'feature',
Banner: 'banner',
Navigation: 'navigation',
Footer: 'footer',
PricingTable: 'pricingTable',
PricingPlan: 'pricingPlan',
Form: 'form',
HubspotForm: 'hubspotForm',
};
```
### ContentTypeMap
```typescript
const ContentTypeMap = {
[ComponentContentTypes.Hero]: Hero,
[ComponentContentTypes.CTA]: CTA,
[ComponentContentTypes.Feature]: Feature,
[ComponentContentTypes.Banner]: Banner,
[ComponentContentTypes.Navigation]: Navigation,
[ComponentContentTypes.Footer]: Footer,
[ComponentContentTypes.PricingPlan]: PricingPlan,
[ComponentContentTypes.PricingTable]: PricingTable,
[ComponentContentTypes.Form]: Form,
[ComponentContentTypes.HubspotForm]: HubspotForm,
};
```
### ComponentRenderer
```typescript
const ComponentRenderer: React.FC = (props) => {
const contentTypeId = get(props, 'sys.contentType.sys.id') as string;
const Component = ContentTypeMap[contentTypeId];
if (!Component) {
console.warn(`${contentTypeId} can not be handled`);
return null;
}
return ;
};
```
### BlockRenderer
```typescript
import { Experience } from '@ninetailed/experience.js-next';
import {
BaselineWithExperiencesEntry,
ExperienceEntryLike,
ExperienceMapper,
} from '@ninetailed/experience.js-utils-contentful';
const BlockRenderer = ({ block }: BlockRendererProps) => {
if (Array.isArray(block)) {
return (
<>
{block.map((b) => (
))}
>
);
}
const contentTypeId = get(block, 'sys.contentType.sys.id') as string;
const { id } = block.sys;
const experiences = (
(block.fields.nt_experiences || []) as ExperienceEntryLike[]
)
.filter((experience) => ExperienceMapper.isExperienceEntry(experience))
.map((experience) => ExperienceMapper.mapExperience(experience));
return (
);
};
```
### Data Flow Summary
```
Contentful Entry (e.g., hero)
|
|- fields.nt_experiences[] (linked nt_experience entries)
| |
| |- ExperienceMapper.isExperienceEntry() -> filter valid
| |- ExperienceMapper.mapExperience() -> ExperienceConfiguration
|
v
|
v
ComponentRenderer receives resolved props (baseline OR variant)
|
v
Hero / CTA / Feature / etc. renders with those props
```
---
## 4. Contentful Client Configuration
### Pattern 1: Simple Client
```typescript
import { createClient } from 'contentful';
export const contentfulClient = createClient({
space: process.env.NEXT_PUBLIC_CONTENTFUL_SPACE_ID ?? '',
accessToken: process.env.NEXT_PUBLIC_CONTENTFUL_TOKEN ?? '',
environment: process.env.NEXT_PUBLIC_CONTENTFUL_ENVIRONMENT ?? 'master',
});
```
### Pattern 2: Dual Client with Preview Support
```typescript
const contentfulClient = createClient({
space: process.env.NEXT_PUBLIC_CONTENTFUL_SPACE_ID ?? '',
accessToken: process.env.NEXT_PUBLIC_CONTENTFUL_TOKEN ?? '',
environment: process.env.NEXT_PUBLIC_CONTENTFUL_ENVIRONMENT ?? 'master',
}).withoutUnresolvableLinks;
const previewClient = createClient({
space: process.env.NEXT_PUBLIC_CONTENTFUL_SPACE_ID ?? '',
accessToken: process.env.NEXT_PUBLIC_CONTENTFUL_PREVIEW_TOKEN ?? '',
host: 'preview.contentful.com',
}).withoutUnresolvableLinks;
const getClient = (preview: boolean) => {
return preview ? previewClient : contentfulClient;
};
```
### `.withoutUnresolvableLinks` Explained
This modifier strips any linked entries/assets that could not be resolved (e.g., because they are in draft, archived, or deleted). Without it, unresolvable links appear as `{ sys: { type: 'Link', linkType: 'Entry', id: '...' } }` objects that can cause runtime errors when you try to access `.fields`.
---
## 5. Data Fetching Patterns
### Page Query with Deep Include
```typescript
interface IPageQueryParams {
slug: string;
pageContentType: string;
childPageContentType: string;
preview?: boolean;
}
export async function getPage(pageParams: IPageQueryParams): Promise {
const client = getClient(!!pageParams.preview);
const entries = await client.getEntries({
limit: 1,
include: 10,
'fields.slug': pageParams.slug,
content_type: 'page',
'fields.content.sys.contentType.sys.id': pageParams.childPageContentType,
});
const [page] = entries.items as IPage[];
return page;
}
```
Key: `include: 10` ensures deep resolution of entry -> nt_experience -> variant -> variant fields.
### Parallel Data Fetching in getStaticProps
```typescript
export const getStaticProps: GetStaticProps = async ({ params }) => {
const rawSlug = get(params, 'slug', []) as string[];
const slug = rawSlug.join('/');
const [page, experiments, experiences, audiences] = await Promise.all([
getPage({
slug: slug === '' ? '/' : slug,
pageContentType: PAGE_CONTENT_TYPES.PAGE,
childPageContentType: PAGE_CONTENT_TYPES.LANDING_PAGE,
}),
getExperiments(),
getAllExperiences(),
getAllAudiences(),
]);
return {
props: {
page,
ninetailed: {
experiments,
preview: { experiences, audiences },
},
},
revalidate: 5,
};
};
```
### Experience Fetching (Experiments vs. All Experiences)
```typescript
// Experiments only (filtered by nt_type)
export async function getExperiments() {
try {
const entries = await client.getEntries({
content_type: 'nt_experience',
'fields.nt_type': 'nt_experiment',
include: 1,
});
return (entries.items as unknown as ExperienceEntryLike[])
.filter(ExperienceMapper.isExperienceEntry)
.map(ExperienceMapper.mapExperiment);
} catch (error) {
console.error(error);
return [];
}
}
// All experiences (experiments + personalizations)
export const getAllExperiences = async () => {
try {
const entries = await contentfulClient.getEntries({
content_type: 'nt_experience',
include: 1,
});
return (entries.items as unknown as ExperienceEntryLike[])
.filter(ExperienceMapper.isExperienceEntry)
.map(ExperienceMapper.mapExperience);
} catch (error) {
console.error(error);
return [];
}
};
// All audiences
export const getAllAudiences = async () => {
try {
const entries = await contentfulClient.getEntries({
content_type: 'nt_audience',
include: 1,
});
return entries.items.filter(AudienceMapper.isAudienceEntry).map(AudienceMapper.mapAudience);
} catch (error) {
console.error(error);
return [];
}
};
```
Key: `getExperiments()` uses `mapExperiment` (singular). `getAllExperiences()` uses `mapExperience`. Both return `[]` on error, never throw.
---
## 6. Experience Component Usage
### With Mapped Experiences from Contentful
```typescript
import { Experience } from '@ninetailed/experience.js-next';
import { ExperienceMapper } from '@ninetailed/experience.js-utils-contentful';
ExperienceMapper.mapCustomExperience(
ctfExperience,
(variant) => ({ id: variant.sys.id, ...variant.fields })
)
)}
passthroughProps={{
onClick: () => {},
test: 'test',
}}
{...entry.fields}
/>
```
### EntryAnalytics (No Personalization, Just Tracking)
```typescript
import { EntryAnalytics } from '@ninetailed/experience.js-react';
```
---
## 7. Personalize Component Usage
The `` component provides inline audience-targeted variants without Contentful experience entries:
```typescript
import { Personalize } from '@ninetailed/experience.js-next';
```
Key: `holdout` controls the holdout percentage (0 = no holdout, 100 = all baseline). Variants include an `audience.id` referencing a Ninetailed audience.
---
## 8. ExperienceMapper Usage
### Reusable Experience Mapper Utility
```typescript
import { ExperienceConfiguration } from '@ninetailed/experience.js';
import { BaselineWithExperiencesEntry, ExperienceMapper } from '@ninetailed/experience.js-utils-contentful';
export const experienceMapper = (entry: BaselineWithExperiencesEntry): ExperienceConfiguration[] =>
entry.fields.nt_experiences.filter(ExperienceMapper.isExperienceEntry).map((experience) =>
ExperienceMapper.mapCustomExperience(experience, (variant) => ({
...variant.fields,
id: variant.sys.id,
hidden: false,
})),
);
```
### mapExperience vs. mapCustomExperience
- `mapExperience` automatically spreads all variant fields. Use in the BlockRenderer pattern where variants match the baseline content type.
- `mapCustomExperience` requires a mapping function. Use when you need to reshape variant data.
```typescript
// mapExperience: automatic mapping (BlockRenderer pattern)
const experiences = block.fields.nt_experiences
.filter(ExperienceMapper.isExperienceEntry)
.map(ExperienceMapper.mapExperience);
// mapCustomExperience: custom mapping (inline pattern)
const experiences = entry.fields.nt_experiences.map((ctfExperience) =>
ExperienceMapper.mapCustomExperience(ctfExperience, (variant) => ({
id: variant.sys.id,
...variant.fields,
})),
);
```
---
## 9. Hook Patterns
### useProfile
```typescript
import { useProfile } from '@ninetailed/experience.js-next';
const { profile, loading, status } = useProfile();
// profile: full Ninetailed profile (traits, audiences, location, session)
// loading: boolean
// status: 'loading' | 'success' | 'error'
```
### useNinetailed
```typescript
import { useNinetailed } from '@ninetailed/experience.js-next';
const { track, identify, page, reset } = useNinetailed();
track('signup_completed', { plan: 'pro' });
identify('external-user-id', { email: 'user@example.com', plan: 'pro' });
page({ custom_property: 'value' });
reset();
```
### Profile Display Component
```typescript
import { useNinetailed, useProfile } from '@ninetailed/experience.js-next';
export const Profile: React.FC = () => {
const { reset } = useNinetailed();
const { profile } = useProfile();
return (
<>