()
useEffect(() => {
if (!sdk) return
const subscription = sdk.states.flag('checkout-banner').subscribe(setValue)
return () => subscription.unsubscribe()
}, [sdk])
return
}
```
Author `checkout-banner` with two distinguishable values, mount this component under the existing
request root, then change the matching visitor state or force a value in the preview panel. Confirm
the output changes. See [Contentful personalization authoring](https://www.contentful.com/developers/docs/personalization/)
and [Custom Flags authoring](https://www.contentful.com/help/personalization/experiences/custom-flags/).
### Preview panel
**Integration category:** Optional
Attach `@contentful/optimization-web-preview-panel` only in development, preview, or staging
environments. The panel needs the live browser SDK and a Contentful client or pre-fetched audience
and experience entries. Keep the environment gate app-owned; do not ship editor tooling to ordinary
production visitors.
**Copy this:**
```sh
pnpm add @contentful/optimization-web-preview-panel
```
The example below uses `NEXT_PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL` as an app-owned environment
gate and `contentfulClient` as an app-owned browser-safe Contentful client. Wait for `isLive` before
attaching; its earlier SDK value is the read-only handoff snapshot. The owned browser root registers
the live SDK that the panel uses by default.
**Adapt this to your use case:**
```tsx
// components/OptimizationPreviewPanel.tsx
'use client'
import { contentfulClient } from '../../../../lib/contentful-client'
import { useOptimizationContext } from '@contentful/optimization-nextjs/client'
import { useEffect } from 'react'
export function OptimizationPreviewPanel() {
const { error, isLive, sdk } = useOptimizationContext()
const enabled = process.env.NEXT_PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL === 'true'
useEffect(() => {
if (!enabled || isLive !== true || sdk === undefined) return
void import('@contentful/optimization-web-preview-panel')
.then(({ default: attachOptimizationPreviewPanel }) =>
attachOptimizationPreviewPanel({ contentful: contentfulClient }),
)
.catch((previewError: unknown) => {
console.warn('Contentful Optimization preview panel failed to attach', previewError)
})
}, [enabled, isLive, sdk])
if (!enabled) return null
if (error) return Optimization preview failed to initialize.
return (
)
}
```
Mount the panel inside the same request root as the content it previews.
The mounting diff below shows normal tracker mode. On a before-initial-page request root, add the
panel but keep `RequestNextAppAutoPageTracker` omitted.
**Adapt this to your use case:**
```diff
// app/(request)/layout.tsx
+import { OptimizationPreviewPanel } from '../../../../components/OptimizationPreviewPanel'
+
{/* Normal tracker mode only. Omit this component in beforeInitialPage mode. */}
{children}
```
In a non-production environment, enable the gate, load the route, and wait for **Optimization
preview ready**. Open the panel, force the authored variant, and confirm that the rendered entry
changes. If attachment fails, the browser console shows the error. When the app already fetched the
panel's audience and experience entries, pass `entries` instead of `contentful`.
## Advanced integrations
### Route-level SSR, browser takeover, and browser-owned islands
**Integration category:** Advanced or production-only
Choose one ownership model per route:
| Route strategy | First paint owner | Browser content behavior | Cache scope |
| ------------------------------ | ----------------------------------------------------------------------------- | ----------------------------------------------------------- | --------------------------------------- |
| Nested request components | Server request | Preserves server output; optional live updates | `private-request` |
| Public permutation handoff | Static generation, Cache Components, or Edge runtime route chosen by app code | Preserves selected output; optional live updates | `public-permutation` with SDK-built key |
| Analytics-only handoff | Server, static, or Edge runtime markup | Tracks page and interactions only; no content re-resolution | Matches the rendered markup owner |
| Client-only hidden-until-ready | Browser SDK | Hides baseline until ready or timeout | Static page shell |
For request-personalized routes, prefer a private-slot composition. Keep public navigation and other
request-independent chrome outside the request root and `Suspense`. Give the private slot a meaningful
fallback, then place the provider-dependent shell body and every SDK-dependent component inside the
request root. In a Next.js 15 or later app that uses Cache Components, put revalidation policy in the
cached component with `use cache`, `cacheLife()`, and `cacheTag()`. Call `connection()` inside the
private slot to keep that boundary on the request-time side of the composition. In Next.js 13 to 14,
omit the `connection()` import and call because that API is unavailable. The request family creates
its visitor-specific `private-request` handoff automatically; the app does not pass that cache scope
to the nested request root.
**Adapt this to your use case:**
```ts
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
}
export default nextConfig
```
**Follow this pattern:**
```tsx
// app/static-shell-private-slot/page.tsx
import { AppShellChrome, PersonalizedContentFallback } from '../../../../components/AppShell'
import { cacheLife, cacheTag } from 'next/cache'
import { Suspense } from 'react'
import { PrivateRequestSlot } from './PrivateRequestSlot'
async function CachedMarketingShell() {
'use cache'
cacheLife('minutes')
cacheTag('static-marketing-shell')
return
}
export default function Page() {
return (
}>
)
}
```
This private-slot example shows normal tracker mode. If the server binding injects
`ClientRequestOptimizationRoot`, remove `RequestNextAppAutoPageTracker` from both this import and the
JSX, as in the atomic callback setup.
**Follow this pattern:**
```tsx
// app/static-shell-private-slot/PrivateRequestSlot.tsx
import { AppShellBody } from '../../../../components/AppShell'
import { RequestNextAppAutoPageTracker, RequestOptimizationRoot } from '../../../../lib/optimization'
// Next.js 15+ with Cache Components. Omit this import on Next.js 13 to 14.
import { connection } from 'next/server'
export async function PrivateRequestSlot() {
// Next.js 15+ with Cache Components. Omit this call on Next.js 13 to 14.
await connection()
return (
)
}
```
`AppShellChrome`, `AppShellBody`, `PersonalizedContentFallback`, `StaticMarketingShell`, and
`PersonalizedPrivateContent` are app-owned components in this pattern. `AppShellChrome` can contain
normal Next.js `Link` components with their default prefetch behavior. The
`static-marketing-shell` cache tag is app-owned. Cache Components do not use route-level
`export const revalidate`; put ISR-style revalidation on the cached component or data function
instead.
If your app is not using Cache Components, do not copy the partial private-slot seam above. Use the
complete
[SSG baseline with browser-owned personalization](/personalization/optimization-sdk/render-personalized-nextjs-routes/#ssg-baseline-with-browser-owned-personalization)
recipe instead.
For complete SSG, App Router Cache Components, Pages Router ISR, Edge runtime, and analytics-only
recipes, use
[Render personalized Next.js routes with static, ISR, and edge handoffs](/personalization/optimization-sdk/render-personalized-nextjs-routes/).
For the mechanics behind handoff state and cache scopes, use
[Optimization handoff and cache-safe rendering](/personalization/optimization-sdk/optimization-handoff-and-cache-safe-rendering/).
### Manual server and client escape hatches
**Integration category:** Advanced or production-only
Use lower-level subpaths only when the bound App Router module cannot express the route. The main
escape hatches are:
* `/server` for direct Node request control with `configureNextjsServerOptimization(...)`. That
helper configures a stateless server runtime; it is not a request-isolation context.
* The top-level `optimization.createRequestHandoff(...)` from the `/app-router/server` binding when
advanced orchestration already owns explicit request, hydration, page payload, and handoff inputs.
* `/app-router/client` for a bound App Router Client Component family.
* `/client` for router-neutral React roots, providers, and hooks.
* `/tracking-attributes` for manually rendered analytics-only markup.
* `/edge` for Edge runtime route handlers that export `runtime = 'edge'` and avoid Node-only APIs.
Manual flows still pass `handoff` to a React root. Do not invent a second state shape for browser
hydration. Keep `createRequestHandoff()` out of the normal private-request route; the nested request
family owns that work.
The response-capable handler is also an advanced opt-in. Configure
`createNextjsOptimizationContextHandler(...)` with a server SDK and consent resolver, then set
`request.trustedRequestHandoff: true` on the App Router binding. That pair allows the request family
to trust compact server context forwarded by the handler. Keep the no-argument forwarding-only
handler for ordinary request-family routes.
Lower-level resolver calls keep selections as the optional second positional argument:
`resolveOptimizedEntry(entry, selectedOptimizations)`. Managed fetch calls accept an ID or a
source object shaped as `{ contentType, slug, slugField?, entryQuery? }`. The ID overload receives
its query in `FetchOptimizedEntryOptions`; the slug source object carries `entryQuery` itself.
`ServerOptimizedEntry` places the element type first, followed by the complete
skeleton union, response mode, and locale.
When lower-level code renders a resolver result directly, `isEmptyVariant === true` marks the SDK
renderer's no-content state; check it before rendering `entry`. The result retains the baseline
entry and selection context for tracking even when consumer output is empty.
### Caching and request deduplication
**Integration category:** Advanced or production-only
`private-request` handoffs include one visitor's request state and must not be stored in a shared
public cache. `public-permutation` handoffs are for app-owned segments, campaigns, markets, or other
choices that are safe to share. Their main inputs are:
* `selectedOptimizations`: The app-supplied list of experience-and-variant choices to render.
* `changes`: The app-supplied Custom Flag name/value changes to hydrate.
* `permutationKey`: An app-owned stable name for the public segment or campaign.
* `cacheVersion`: An optional app-owned version token that changes the generated cache identity when
the rendered rules change.
Pass those values, plus the locale and rendered entry IDs, to
`createPublicPermutationHandoff()`. The helper serializes the supplied state and creates public
cache metadata; it does not discover a segment or derive selected optimizations from a route,
cookie, header, locale, or cache key. Because `changes` are handoff state rather than part of the
generated cache-key fingerprint, rotate `cacheVersion` when rendered Custom Flag values change.
`static` handoffs are for baseline or build-time output that does not depend on a request profile.
Do not create public or static handoffs from request-derived profile state.
Use the supplemental rendering guide for static generation, App Router Cache Components, Pages
Router ISR, Edge runtime, and analytics-only recipes. Use the handoff concept when reviewing
whether a route can be public, public-permutation, static, or private-request cached.
Within one React Server Component request, every `optimization.request` wrapper shares one
SDK-owned initialization. Managed entries use the SDK's managed cache and in-flight deduplication.
Their baseline fetch or root prefetch starts alongside request initialization. A request entry waits
for its baseline and selected request state before resolving; a request root waits for prefetch and
request state before merging the fetched entries into the handoff once. Separate requests remain
isolated. These responsibilities are different from caching rendered output. Do not add an app-owned
React request cache, request shell, duplicate layout/page await, or performance setting around the
request family.
Validate visitor isolation with two browser profiles whose consent or identity selects different
authored text. Use View Source in each profile and confirm profile A receives only variant A while
profile B receives only variant B. Reload both profiles and repeat the check. A value crossing
between profiles means visitor-specific HTML entered a shared output cache; it is not an SDK managed
entry-cache hit.
### Strict consent and duplicate-event controls
**Integration category:** Advanced or production-only
When no Optimization event may emit before explicit consent, configure a strict event policy and
return `false` from `consent.server` until your app-owned consent record is accepted. In normal
tracker mode, the request tracker receives first-page-event ownership from its handoff. In
`beforeInitialPage` mode, mount no request tracker; the root owns the direct attempt and later
routes. For top-level explicit handoff flows, use `initialPageEvent="skip"` only when a server or
edge helper already accepted the same route's first page event. Use blocked-event diagnostics to
verify denied events are dropped at the SDK boundary.
`allowedEventTypes` is the binding's pre-consent event allow-list. An empty list makes every event
require accepted event consent.
**Adapt this to your use case:**
```diff
// lib/optimization.ts - existing binding
export const optimization = bindNextjsAppRouterServerOptimization({
// Existing project and Contentful config.
+ allowedEventTypes: [],
consent: {
server: ({ cookies }) => cookies.get('app-consent')?.value === 'accepted',
clientDefaults: { consent: false, persistenceConsent: false },
},
})
```
Keep `OptimizationEventDiagnostics` before the selected page owner. In normal tracker mode, that
means before `RequestNextAppAutoPageTracker`; in `beforeInitialPage` mode, the diagnostic stays
inside the root and no tracker is mounted. Clear the `app-consent` cookie, reload, and confirm the
browser console reports a blocked record whose `reason` is `consent` and whose `method` is `page`. Click
**Allow personalization** in the control shown earlier, then follow a normal Next.js `Link` to
another participating route. Confirm that the navigation produces one locally accepted `page`
event. In normal tracker mode, the handoff tells the tracker to skip a server-owned duplicate. In
`beforeInitialPage` mode, the root's initial non-emitting mark prevents a same-route retry.
Consent withdrawal has separate owners: record denial in your app or consent-management platform,
call `setConsent(false)` to stop and clear SDK durable event storage, and call `resetUser()` to clear
the active SDK profile and selected optimizations. The SDK does not erase the app-owned consent or
account record.
## Production checks
* Confirm server and browser config use the intended Contentful space, environment, locale, and
Contentful space ID.
* Confirm `consent.server`, browser consent defaults, and app-owned consent storage agree.
* Confirm `ctfl-opt-aid` is browser-readable where server and browser profile continuity is needed.
* Confirm locally accepted server and browser events arrive at the intended Experience or Insights
API destination; the browser diagnostic alone is not delivery evidence.
* Confirm first-page ownership matches one mode. In normal tracker mode, the request tracker skips a
server-owned first event and emits later routes. In `beforeInitialPage` mode, no request tracker
is mounted and the root's direct attempt plus built-in emitter do not duplicate the initial route.
* Confirm baseline fallback is acceptable when no variant applies or Contentful links are
unresolved.
* Confirm request-personalized output is never stored in a public shared cache.
* Run your app's existing typecheck, lint, production build, and browser E2E scripts. Script names
are app-owned; use the commands already declared in your app's `package.json`.
* Compare the result with the maintained App Router reference implementation's
[local run instructions](https://github.com/contentful/optimization/blob/optimization-swift-v2.0.1/implementations/nextjs-sdk_app-router/README.md#running-locally) and
[E2E instructions](https://github.com/contentful/optimization/blob/optimization-swift-v2.0.1/implementations/nextjs-sdk_app-router/README.md#running-e2e-tests).
The following commands run the reference implementation from this monorepo; they are not commands
to copy into an unrelated application.
**Reference excerpt:**
```sh
pnpm setup:e2e:nextjs-sdk_app_router
pnpm test:e2e:nextjs-sdk_app_router
```
## Troubleshooting
| Symptom | Likely cause | Check |
| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Entries stay on baseline | No matching variant, no selections after a blocked Experience event, unresolved variant links, or all-locale CDA payload | Target all visitors for the first test, read accepted or blocked events, and fetch one locale with enough `include` depth |
| A heterogeneous render cannot read content-type-specific fields | The skeleton union omits a possible content type, or the entry was not narrowed before rendering | Include every baseline and variant skeleton in `S`, then narrow with `isEntryOfContentType` |
| Variant appears in the browser but not View Source | The route is browser-owned rather than request-family or public-permutation rendered | Use `optimization.request` for private request rendering, or use a top-level public permutation handoff before rendering |
| Request components report a missing forwarded request URL | The handler filename or export name does not match the Next.js version, or the handler is absent | Configure the SDK request handler; use `proxy.ts` with `proxy` on Next.js 16, or `middleware.ts` with `middleware` on Next.js 13 to 15 |
| Duplicate first page events | Normal tracker mode has conflicting tracker ownership, or `beforeInitialPage` mode still mounts a request tracker | In normal tracker mode, give the tracker the handoff's `initialPageEvent`; in `beforeInitialPage` mode, remove the request tracker and let the root own initial and later pages |
| Live entries do not change after identify or reset | The entry is locked to the handoff and live updates are off | Set `liveUpdates: true` on the App Router binding or use `/client` `LiveUpdatesProvider` for a browser subtree; for one entry, use router-neutral `/client` `OptimizedEntry liveUpdates` because bound App Router entries have no per-entry `liveUpdates` prop |
| Personalized HTML is cached for the wrong visitor | Request handoff output entered a public cache | Use `private-request` for request state and public permutation handoffs only for app-owned selected permutations |
## Reference implementations to compare against
* [Next.js SDK App Router reference implementation](https://github.com/contentful/optimization/blob/optimization-swift-v2.0.1/implementations/nextjs-sdk_app-router/README.md)
* [Next.js SDK App Router Edge runtime reference implementation](https://github.com/contentful/optimization/blob/optimization-swift-v2.0.1/implementations/nextjs-sdk_app-router_edge-runtime/README.md)
* [Next.js SDK Pages Router reference implementation](https://github.com/contentful/optimization/blob/optimization-swift-v2.0.1/implementations/nextjs-sdk_pages-router/README.md)
> This guide helps you render a personalized Contentful entry on the server in a Next.js App Router app and keep the same result when the browser starts, using an Optimization handoff.