Optimization handoff and cache-safe rendering

Overview

Use this document to understand how server, static, and edge-rendered Optimization state reaches the browser without putting visitor-specific profile state into public caches. It applies to the Next.js SDK, React Web SDK, and Web SDK surfaces that consume an Optimization handoff.

For setup steps, use the relevant Next.js integration guide. This concept explains the mechanics behind those guides: what a handoff contains, who owns cacheable permutations, how hydration differs from live updates, and how analytics-only markup can still carry Optimization tracking metadata.

Runtime support

Runtime surfaceHandoff role
@contentful/optimization-nextjs/app-router/serverBinds explicit-input App Router server components and helpers plus the nested request component family.
@contentful/optimization-nextjs/app-router/clientBinds App Router browser roots, entries, trackers, and explicit handoff helpers.
@contentful/optimization-nextjs/pages-router and /pages-router/serverBinds Pages Router roots, getServerSideProps request handoff helpers, and public permutation handoff helpers.
@contentful/optimization-nextjs/edgeConfigures Edge runtime request handoff and public permutation handoff helpers.
@contentful/optimization-nextjs/request-handlerForwards sanitized request context through pass-through responses and can perform response-capable server page work before App Router Server Components render.
@contentful/optimization-nextjs/cache-middlewareRewrites pass-through Next.js proxy or middleware requests to the public permutation cache key produced by the same metadata helper used by handoffs.
@contentful/optimization-nextjs/tracking-attributesProduces server, static, and edge data-ctfl-* tracking attributes for manual rendering paths.
@contentful/optimization-react-webConsumes content handoffs in OptimizationRoot and analytics-only handoffs in OptimizationAnalyticsRoot.
@contentful/optimization-webHydrates content handoffs into a live browser SDK and analytics-only handoffs into a narrow analytics runtime.

Inputs and constraints

An OptimizationHandoff is the framework-neutral shape for state produced before browser hydration. It can contain:

  • state.selectedOptimizations - the selected experience and variant records used for entry resolution.
  • state.changes - Custom Flag changes derived from the selected optimizations.
  • state.profile - profile state from a request-backed Experience API response.
  • entries - managed-entry baseline snapshots that let browser-managed ID or content-type/slug sources hydrate from the same baseline entry the server or static render used. A slug handoff nests its normalized lookup descriptor under managedEntry and retains the fetched entry’s sys.id in entryId.
  • cache - metadata that describes where the rendered output is allowed to be cached.

Browser handoffs add two fields:

  • hydration - the browser presentation policy for already-rendered content.
  • initialPageEvent - whether the browser emits or skips the first page event for this route.

The SDK serializes and hydrates the state it receives. Browser hydration applies only state fields that are present on the handoff. During Web handoff state interception, omitted interceptor fields keep the incoming handoff value, while an own field whose value is undefined is applied intentionally. Hydrating an undefined or empty handoff state still marks the browser Experience request state as successful. For content handoffs, that successful empty hydration clears stale selected optimizations and changes while preserving the existing profile unless the handoff includes its own profile field.

The SDK does not infer application segments, campaigns, markets, or other public permutations from the URL. When a handoff is cacheable across visitors, application code supplies the selected optimizations and cache key.

Mental model

Handoff is a render boundary. A server, build, Pages Router ISR, App Router Cache Components, or Edge runtime path resolves content using a known Optimization state, then passes that same state to the browser so the first browser render matches the markup.

request, build, ISR, Cache Components, or Edge runtime code chooses Optimization state
-> route resolves entries or renders analytics-only markup
-> route creates a browser handoff with cache metadata
-> browser root hydrates from the handoff
-> browser SDK takes over according to hydration and liveUpdates policy

The handoff is not a cache key by itself. Cache safety comes from matching the rendered output, the handoff state, and the cache scope.

In App Router, managed-entry prefetch without a supplied handoff creates a baseline static handoff with hydration: 'preserve-server', selectedOptimizations: [], and initialPageEvent: 'emit'. Treat it as baseline entry warming, not request-personalized state. Prefetch accepts ID and content-type/slug descriptors. A matching browser source uses the handed-off baseline through either the source key or resolved sys.id, so it does not repeat the CDA request.

For private App Router rendering, the server binding’s nested request family owns the handoff boundary. Its components share one request initializer that reads the active request, derives the render inputs, creates a private-request handoff, and supplies that handoff to the bound root or provider. Application code does not need a request cache, route shell, duplicate awaits, or manual header, cookie, URL, route-key, or page-payload plumbing. Top-level server components remain explicit-input surfaces for static, public-permutation, analytics-only, and advanced manual flows.

Cache scopes

ScopeUse it forCache rule
private-requestA real request backed by cookies, request headers, profile continuity, or an Experience API response for one visitor.Keep the rendered output out of shared public caches.
public-permutationA customer-owned segment, market, campaign, or path where application code already chose the selected optimizations.Provide a helper-built cache.key that covers the full public permutation.
staticBuild-time or baseline output that does not depend on request profile state.Do not include profile state.

getOptimizationCacheSafetyWarnings() reports diagnostics without blocking rendering. assertOptimizationCacheSafety() throws a TypeError for the same unsafe states, and SDK handoff constructors call it for created handoffs. Browser content and analytics handoff hydration also call it before writing handoff state into the live Web SDK. Request handoff helpers are stricter before request evaluation: they accept private-request cache metadata and reject public-permutation or static cache metadata with a TypeError. Public permutation metadata requires a non-empty key at the type level and at runtime.

React Web validates content handoff cache safety before children read the initial snapshot runtime. An unsafe public or static handoff with profile state fails before the first handoff-backed render.

Customer-owned permutations

A customer-owned public permutation is a cacheable output your application can name from a finite app-owned or customer-owned registry without reading a visitor profile. The registry can come from a segment service, CMS or config mapping, static artifact, or reviewed app config.

The public/static handoff helpers serialize the selected optimizations, changes, entries, and cache metadata your application supplies. They do not call the Experience API or derive selections from route, cookie, header, locale, or cache-key inputs.

A registry record is valid for a static, ISR-style, or Edge runtime public output only when it has enough dimensions to fetch, resolve, hand off, and cache one output: public key or slug, locale, baseline entry IDs, selectedOptimizations, optional rendered changes, and an app-owned revision or cache version. The app-owned revision must change when selected optimizations, rendered Custom Flag changes, rendered entries, locale, content environment, or cache policy change. The SDK serializes and hydrates the state you supply. It does not discover public permutations for the application.

The validity boundary is cache ownership. Public and static handoffs must use app-owned selections, not request profile, cookies, headers, or request-derived selection data. Public cache keys come from createPublicPermutationCacheMetadata() or a framework helper that calls it. The helper encodes key fields such as permutation=... and version=..., then includes scope, locale, baseline entry IDs, and selected optimizations in the suffix. cacheVersion is optional in the API, but cacheable public routes should supply it so the app has an explicit invalidation dimension. The generated cache.key does not fingerprint Custom Flag changes; if rendered flag values affect the output, represent that dimension through cacheVersion or another caller-owned key. The generated key is deterministic SDK identity and transport metadata; it is not a Next.js use cache key and is not a cacheTag() or revalidateTag() tag.

Next.js public permutation middleware consumes the same public metadata object. Its default rewrite uses the SDK-owned ctfl-opt-cache-key query parameter, and custom rewrites receive an encodedCacheKey for locations that need an already-encoded value. Next.js tags are caller-owned invalidation labels. When supplied to the Next.js helpers or middleware metadata, use no more than 128 tags; each tag must be a non-empty string after trimming, 256 characters or fewer, and must not include commas. App Router Cache Components can pass short custom tags to cacheTag(). Pages Router ISR and Edge runtime public routes can omit tags unless the app wires tag invalidation. For public permutation middleware, existing middleware or proxy rewrites, redirects, or other terminal responses are returned unchanged. The request-context handler is different: it preserves an existing rewrite response while still applying SDK request context and eligible profile-cookie persistence. Pass-through responses keep flowing through the Optimization rewrite or request-context path.

For selected-optimization shape, content model, variant-index, and fallback details, see Entry optimization and variant resolution. For the procedural Next.js recipe, see Rendering personalized Next.js routes with static, ISR, and edge handoffs.

Hydration and live updates

hydration controls the first browser presentation over already-rendered markup.

Hydration modeEffect
preserve-serverKeep server, static, ISR-style, or Edge runtime-rendered content visible while the browser hydrates; resolved metadata waits for settled state.
client-only-hidden-until-readyLet the browser own content resolution; the default loading presentation can hide the baseline layout target until resolution settles or times out.
analytics-onlyHydrate analytics state without providing content resolution context.

liveUpdates controls later browser re-resolution after startup. A route can preserve the rendered content for stable first paint and still keep live updates off. Turn live updates on when visible content must react to consent, identity, profile, or preview changes after hydration. Preview state can force live re-resolution for authoring flows.

When Web or React Web hydrates a profileless static or public-permutation content or analytics handoff, the handoff state can affect live browser memory for that page, but the SDK preserves existing durable browser profile continuity by suppressing durable continuity persistence for that handoff. A private-request handoff, or any profile-backed handoff that passes cache safety, follows normal persistence behavior when persistence consent allows.

Initial page event ownership

The first page event must have one owner.

  • Use initialPageEvent: 'skip' when a request or edge helper already accepted the first page event for the same route.
  • Use initialPageEvent: 'emit' when the browser owns the first page event for a static, public-permutation, or browser-owned route.

Next.js request helpers set this value from the accepted page event result. The App Router request family passes its handoff-owned value to the nested route tracker. When the binding opts into trusted request handoff, a response-capable request handler can forward pageAccepted so the Server Component path does not call page() a second time. That forwarded context is compact: consent, pageAccepted, and optional profileId. The request family refetches profile and selection state server-side when profileId is present instead of forwarding full OptimizationData. Manual createRequestHandoff() remains available for advanced orchestration. Selection handoff helpers require application code to supply the initial page-event owner because customer-owned static and public permutations do not emit a server request event by themselves.

React Web roots can emit the handoff-owned initial page event when they receive routeKey and either buildPagePayload or initialPagePayload. A skip can mark the initial route accepted with only the route key. A skip applies only to the first route hydrated from that handoff; later route-key changes emit browser page events. Next.js route trackers use the same "emit" or "skip" control for the first browser route and then track later navigations.

Analytics-only handoff and tracking attributes

Some server, static, ISR-style, or Edge runtime routes render the final HTML themselves and use the browser SDK only for page and interaction tracking. Those routes use an analytics-only handoff:

  • OptimizationAnalyticsRoot hydrates an analytics runtime, not a content resolution provider.
  • getServerTrackingAttributes(baselineEntry, resolvedData) attaches the SDK-owned data-ctfl-* attributes that browser entry-interaction tracking consumes.
  • The data-ctfl-* attributes describe the resolved entry, baseline entry, optimization context, variant index, sticky selection, and clickable state.

When an analytics-only handoff skips the initial route, React StrictMode effect replay does not turn that skip into a duplicate browser page event. Later route-key changes still emit route events through the analytics runtime. If a newer analytics hydration starts or the root unmounts before async hydration finishes, the stale hydration stops before writing state, warning, or tracking the page.

Analytics-only rendering still needs the same cache decision as the markup it tracks. A static analytics handoff is static; a public permutation needs an application-owned key; request-personalized markup remains private to the request.

Why profile state stays out of public caches

Profile state is visitor-specific. Request-backed selected optimizations, Custom Flag changes, merge tag values, and rendered personalized HTML can all depend on that profile. If that state enters a shared public cache, another visitor can receive the wrong variant, wrong Custom Flag state, wrong merge-tag output, or a page-event handoff that was created for a different profile.

Use request-backed handoffs for private request rendering. Use public permutation handoffs for cacheable app-owned permutations. Cache raw Contentful baseline entries according to your application policy, but keep resolved personalized output scoped to the state that produced it.