Render personalized Next.js routes with static, ISR, and edge handoffs
Overview
Use this guide to render a Next.js route from application-chosen Optimization selections, cache the result at the right scope, and hydrate the browser from the same state.
This guide supplements the App Router and Pages Router integration guides. It assumes the SDK is
already configured and focuses on rendering strategy. Code snippets use App Router file names unless
a section says otherwise. App Router Cache Components use use cache, cacheLife(), and
cacheTag() for ISR-style revalidation. Pages Router apps use getStaticProps,
getStaticPaths, and revalidate for ISR, and the maintained Pages Router reference implementation
validates both the getServerSideProps request-handoff path and a content-capable ISR public
permutation route.
Glossary:
- A browser handoff is the serializable Optimization state passed from server, static, Pages Router ISR, App Router Cache Components, or application-owned Edge runtime output to the browser.
- A public permutation is a cacheable route output your application can name without reading a visitor profile, such as a segment, market, campaign, or locale route.
- Selected optimizations are the selected experience and variant records for one permutation. In this guide, your application supplies them from a segment service, CMS config, static file, or other app-owned source.
- Hydration is the first browser render over existing markup.
liveUpdatesis later browser re-resolution after startup. - Customer-owned means owned by your application team. It does not mean a site visitor owns the selection.
- Cache scope and hydration strings such as
public-permutation,static,private-request,preserve-server,client-only-hidden-until-ready,analytics-only,emit, andskipare SDK-owned exact values. Route keys, payloadproperties, environment variable names, and helper names are application-owned.permutationKey,cacheVersion, and Next.js tags are application-owned cache inputs;handoff.cache.keyandctfl-opt-cache-keyare SDK-generated cache metadata.
Here, edge-side rendering (ESR) means a Next.js Edge route owns the response before it reaches the
browser. The public SDK entrypoint for Edge handoff state is
@contentful/optimization-nextjs/edge; HTML or JSON rendering stays application-owned.
Do you need this?
Use this guide when a route uses one of these strategies:
- static generation where the browser SDK chooses variants after hydration;
- static generation, App Router Cache Components, or Pages Router ISR for app-owned segments, markets, campaigns, or other public permutations;
- edge route-handler handoff for a public permutation;
- edge request handoff where the route owns a
Response; - analytics-only server, static, ISR-style, or Edge runtime markup that needs Optimization tracking attributes.
Skip this guide for the App Router nested request-component path or the Pages Router
getServerSideProps request-handoff path. Those flows are covered in the integration guides and use
private-request cache scope.
Request-derived profile handoffs are private-request only; public and static handoffs use
app-owned selections.
Quick start
Start with one App Router Cache Components route whose permutation is owned by your application. The example expects these app-owned helpers:
@/lib/optimizationexports theoptimizationserver binding from@contentful/optimization-nextjs/app-router/server. The examples alias its top-level root asExplicitOptimizationRoot. These explicit-input exports are separate from the nested private request family.getHeroEntry()fetches the baseline Contentful entry for the route.getPublicSegments()returns the public segment slugs that Next.js can pre-render.getPublicSegment(slug)returns{ slug, locale, baselineEntryIds, selectedOptimizations, changes?, cacheVersion }.baselineEntryIdsnames the baseline entries rendered by this route.changes?is the optional Custom Flag change array from the same approved source asselectedOptimizations.cacheVersionis an app-owned value you change when the segment’s selected optimizations, rendered Custom Flag changes, rendered entry set, or cache policy changes.
selectedOptimizations must use the SDK’s selected-optimization shape. Store it in your app from a
segment service, CMS config, static build artifact, or another app-owned source that already knows
which experience and variant this public route represents. Do not build a public permutation by
reading the current visitor profile or expecting the handoff helper to derive selections from route,
cookie, header, locale, or cache-key inputs. changes is the optional Custom Flag change array from
the same approved source as selectedOptimizations; omit it when the route does not render Custom
Flag values.
Reference excerpt:
The SDK hydrates the selected state your app supplies; it does not discover the segment or choose the
selected optimizations. Start with one approved segment whose selected variant has distinctive text;
the route below fetches the baseline and calls resolveEntriesForSelections() with that segment.
Each resolved item also carries optional isEmptyVariant. When it is true, entry retains the
baseline for tracking context, but direct route markup must omit consumer content. An absent flag
renders normally.
Adapt this to your use case:
Verify that first segment before adding more:
- Request
/segments/<slug>with your normal Next.js dev or preview command running. - Open View Source and find the distinctive variant text in the raw HTML.
- Load the page normally and verify the same text remains after hydration.
- During first validation, log
handoff.cache.keynext to the handoff creation, changesegment.cacheVersion, request the route again, and verify the logged key changes. Remove the temporary log after the route is validated.
This quick-start proof does not validate tag invalidation. cacheTag() and revalidateTag() are
application-owned Next.js invalidation paths; validate them with your production webhook, Server
Action, or Route Handler outside the first route proof.
Default recipe
Use the same ownership test for every route: the cache owner must match the Optimization state that
produced the markup. A baseline entry is the Contentful entry before Optimization resolution.
Resolving entries means applying selected optimizations to those baseline entries before rendering.
initialPageEvent tells the browser whether to emit or skip the first page event for the route.
Build app-owned public permutations
An app-owned public permutation starts in a finite registry your application owns. The registry can live in app code, a CMS mapping entry, a segment service, a generated static artifact, or reviewed config.
Use one app-owned source of truth for each registry. Segment services, CMS config, and static artifacts can store the selected-optimization records that your application has approved for each public segment, market, campaign, route, or locale.
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.
When the browser hydrates a profileless static or public-permutation handoff, the SDK applies
the selected optimizations and Custom Flag changes to live browser state for that page without
overwriting durable browser profile continuity. private-request handoffs, and profile-backed
handoffs that pass cache safety, keep the normal persistence behavior when persistence consent
allows.
Each registry record needs enough information to fetch, resolve, hand off, and cache one public output:
Reference excerpt:
Prefer selected-optimization records generated from reviewed segment, CMS, or static config based on the same Optimization content model. Hand-author this shape only when your app config uses matching experience and variant entry IDs copied from Contentful or reviewed selection data. A selected-optimization record has four fields:
The helper API can omit cacheVersion, but this guide and the reference implementations include it
for cacheable public routes so route code has an explicit invalidation dimension.
Validate each permutation before you add it to SSG, ISR-style Cache Components, Pages Router ISR, or edge rendering:
- Confirm
selectedOptimizationsis an array.createPublicPermutationHandoff()rejects non-array values. - Fetch each baseline entry with one concrete locale and enough
includedepth to resolve the SDK-ownednt_experiencesandnt_variantslinks, and include thent_configfield on each attached optimization entry.nt_experiencesis the baseline entry field that links attached optimization entries.nt_configis the optimization entry field that describes entry-replacement components.nt_variantsis the optimization entry field that links replacement entries. - Confirm every
experienceIdyou expect to affect a baseline entry matches the attached optimization entry’sfields.nt_experience_id. The attached optimization entry uses the SDK-ownednt_experiencecontent type. - Confirm the selected
variantIndexexists in the configured variants for that baseline entry. - Confirm the selected variant entry is resolved in the Contentful payload and uses the same content type as the baseline entry.
- Run resolution for one known non-baseline permutation and inspect
resolved.entry.sys.id. It must equal the expected variant entry ID. For an intentionalvariantIndex: 0permutation, it must equal the baseline entry ID. - Treat unexpected baseline output as a failed validation for non-baseline permutations. The supported fallback contract is baseline output when no matching selection exists, Optimization links are unresolved, or the payload uses all-locale fields.
Resolve and assemble each usable permutation in the route that renders it:
- Load the registry record by public key, slug, path, market, campaign, or locale.
- Fetch the baseline entries named by
baselineEntryIdswith the record’slocaleand include depth. - Call
resolveEntriesForSelections()with those baseline entries and the record’sselectedOptimizations. - Call
createPublicPermutationHandoff()with the same public key,cacheVersion, locale, entry IDs,selectedOptimizations, optionalchanges, hydration mode, and initial page-event ownership used by the route. - Use
cache: { scope: 'static' }withcreateHandoffFromSelections()for one build-time static output. UsecreatePublicPermutationHandoff()for Cache Components, Pages Router ISR, Edge runtime, or CDN-cached public outputs. - Use
initialPageEvent: 'emit'unless a request or edge helper already accepted the first page event for the same route.
Follow this pattern:
getBaselineEntries() is your Contentful fetch helper. permutation is the app-owned registry
record. Keep profile, cookies, headers, and request-derived selection data out of public and static
handoffs. The SDK serializes and hydrates the state you supply; it does not discover public
permutations for the application.
createPublicPermutationHandoff() creates cache: { scope: 'public-permutation' } for you. Your
application owns the permutationKey, cacheVersion, locale, entry IDs, selected optimizations,
and optional tags it passes in. The SDK owns the generated handoff.cache.key, which uses encoded
fields such as permutation=segment-a:version=v3:...; the remaining suffix covers scope, locale,
entry IDs, and selected optimizations. Because Custom Flag changes are handoff state rather than
part of that generated cache-key fingerprint, rotate cacheVersion or another app-owned key
dimension when rendered flag values change. Treat handoff.cache.key as deterministic SDK identity
and transport metadata, not as a Next.js use cache key and not as a cacheTag() or
revalidateTag() tag.
Next.js tags are caller-owned invalidation labels. Pass tags only when the route wires tag
invalidation, such as an App Router Cache Components route that calls cacheTag(). Supplied tags
must include no more than 128 values; each value must be a non-empty string after trimming, 256
characters or fewer, and must not include commas. Pages Router ISR and Edge runtime public routes
can omit tags unless they wire tag invalidation.
Use preserve-server hydration when the route already rendered the selected content. Use
client-only-hidden-until-ready when the browser owns content resolution and the route must avoid a
visible baseline flash.
Runtime or vendor variants
SSG baseline with browser-owned personalization
Use this when the route can serve a static baseline shell and the browser can resolve content after hydration. The server or build does not create a handoff because no selected Optimization state exists before hydration.
Define useOptimizationConsent() as an app-owned client hook that reads your consent record and
returns { events, persistence } booleans for Optimization event delivery and profile-cookie
persistence.
This example intentionally combines two client entrypoints from the same installed package.
/app-router/client owns the App Router navigation tracker; router-neutral /client owns the root
and entry because this component supplies browser configuration and consent directly. Both consume
the nearest Optimization React provider.
Adapt this to your use case:
NEXT_PUBLIC_OPTIMIZATION_CLIENT_ID and NEXT_PUBLIC_CONTENTFUL_ENVIRONMENT are reader-owned
browser-visible environment variable names. consent controls whether SDK events can personalize or
emit; persistenceConsent controls whether the browser can store SDK profile continuity.
For this browser-owned route, set your app-owned consent record to allow Optimization events during
the proof, then verify in the rendered page or browser devtools after hydration, not in View Source.
NextAppAutoPageTracker owns the first page event because this route has no server handoff.
SSG customer-owned static permutation
Use this when build code already knows the selected optimizations for one static output. The handoff
uses static because there is no request profile and no ISR or CDN permutation key.
Adapt this to your use case:
Use a separate static route or path for each public output. Do not add request profile state to a
static handoff.
App Router Cache Components public permutation
Use this when an App Router public permutation can be regenerated and cached independently with
Cache Components. The quick start uses this strategy. Cache Components do not use route-level
export const revalidate; put the revalidation policy in the cached component or data function.
Follow this pattern:
The helper creates public-permutation cache metadata from the same route dimensions the handoff serializes.
Tag invalidation remains application-owned. If the route wires a webhook, Server Action, or Route
Handler that calls revalidateTag('segment:<slug>'), trigger that path in production validation and
verify the next request renders from updated cached data. If no invalidation path exists, cacheTag()
only labels the cached work and does not prove invalidation.
For Pages Router ISR, use the same public-permutation handoff in getStaticProps, return
revalidate, and enumerate finite paths with getStaticPaths.
Public permutation middleware rewrites
Use this when a proxy or middleware layer routes a visitor to the correct pre-rendered public
permutation. The middleware must return the same public cache metadata shape used by
createPublicPermutationHandoff(). Invalid metadata throws instead of silently falling back to an
unsafe route. The default rewrite writes the raw key to the SDK-owned ctfl-opt-cache-key query
parameter, and the URL layer encodes it in the rewritten URL. Use encodedCacheKey from the rewrite
context when a custom rewrite embeds the key in a path segment or another already-encoded location.
If middleware metadata includes tags, the same Next.js tag limits apply. Use proxy.ts with
proxy on Next.js 16, or middleware.ts with middleware on Next.js 13 to 15; the handler body is
the same.
Adapt this to your use case:
getPublicSegmentForRequest() is your application lookup. It must return application-supplied
selected optimizations and cache metadata, not selections derived from a visitor profile, cookie, or
header.
Edge public permutation
Use this when an Edge runtime route handler chooses a public permutation without reading a request
profile. The route must export runtime = 'edge' and avoid Node-only APIs.
The route can return an application-owned Response and still use public-permutation cache metadata
because the selected optimizations are supplied by application code. This is the /edge helper
boundary; an App Router page that imports the top-level bound React OptimizationRoot and returns
React markup from runtime = 'edge' is outside this guide. NEXT_PUBLIC_OPTIMIZATION_CLIENT_ID and
CONTENTFUL_ENVIRONMENT are reader-owned environment variable names in this excerpt. The latter is
server-only; give it the same environment value as the browser binding’s
NEXT_PUBLIC_CONTENTFUL_ENVIRONMENT. Keep visitor-profile, cookie, header, and other
request-derived selections out of this public path.
configureNextjsEdgeOptimization(...) configures stateless Edge helpers for the route module; it is
not a per-request isolation context.
Adapt this to your use case:
renderEdgeSegmentResponse() is your existing edge-safe renderer that returns a Response and
serializes the handoff for the browser root that will hydrate the output. Keep the bound App Router
module, profile cookies, and request-derived selected optimizations out of this path. If the route
reads visitor state, use a private-request handoff.
Edge request-personalized handoff
Use this when an Edge runtime route owns a Response and renders for the current request. The route
must export runtime = 'edge' and avoid Node-only APIs. This is a reference excerpt for custom
route handlers that already turn application HTML into a Response; it is not an App Router page
recipe. The helper reads request cookies and headers, emits the page event, returns a browser
handoff, and gives the route a persist(response) callback for the SDK-owned anonymous ID cookie.
app-consent is a reader-owned consent cookie name. Configure consent.server explicitly; if it is
omitted, Edge request consent resolves to false.
Reference excerpt:
renderPersonalizedResponse() is your existing custom renderer that returns a Response. Keep the
response private because the handoff can include request profile state.
Analytics-only server, static, or edge markup
Use this when the route already renders the final entry output and needs browser page or interaction
tracking without browser content re-resolution. Analytics state is the selected Optimization context
needed for page and entry-interaction events. Content-resolution state is the data a content-capable
OptimizationRoot or OptimizationProvider uses to choose and render entry variants.
OptimizationAnalyticsRoot hydrates the analytics state only; it does not let child components
resolve content in the browser.
Adapt this to your use case:
data-ctfl-* tracking attributes are SDK-owned. A resolved entry ID is the ID of the baseline or
variant entry that the route rendered. Attach the attributes to the element that represents that
entry so browser interaction tracking can read the entry ID and Optimization context.
If you build the analytics-only browser owner without React, import
initializeOptimizationAnalyticsRuntime(...) and hydrateOptimizationAnalyticsHandoff(...) from
@contentful/optimization-web/analytics. Initialize one analytics-only runtime for the page and
hydrate each analytics-only handoff into it; the runtime does not expose content-resolution APIs and
is not an isolation context. When route changes can replace a handoff before hydration finishes, pass
the helper’s isCurrent option so stale hydration stops before state or page tracking applies.
Validate the integration
- At the handoff creation point, log
handoff.cacheduring first validation and verify public routes usepublic-permutationorstatic, while request-personalized routes useprivate-request. - For customer-owned permutations, inspect the logged handoff and verify
handoff.state?.profileis absent. That property is the optional per-visitor profile snapshot; a public or static handoff cannot carry it safely. - For every
public-permutationhandoff, inspect the loggedhandoff.cache.keyand verify it starts with encoded fields such aspermutation=...:version=...:and changes when the segment, locale, selected optimization set, entry set, or app-owned cache version changes. If rendered Custom Flag changes affect the output, verify those changes also rotate the app-owned cache version or another caller-owned key dimension. - If you pass custom tags, verify
handoff.cache.tagsequals that caller-owned list and each tag satisfies the Next.js tag limits. - If the route uses tag invalidation, trigger the application-owned path that calls
revalidateTag(...)and verify the next request renders updated cached data. If no such path exists, do not count tag invalidation as validated. - For server, static, ISR-style, and application-owned Edge runtime responses that render HTML, open View Source and find distinctive variant text from the selected permutation.
- For Pages Router ISR, request a prerendered public permutation in the mode where ISR runs and
verify the response cache header matches the route’s
revalidatepolicy, for examples-maxage=60when the route returnsrevalidate: 60. - With
hydration: 'preserve-server', load the page normally and verify the same distinctive text remains after hydration. - For browser-owned routes, skip View Source for the variant proof; verify the variant in the rendered page or browser devtools after hydration.
- For a bound App Router path, mount the integration guide’s event observer inside that route’s root. Trigger the first page load and inspect the accepted or blocked record in the browser console.
- For analytics-only markup, inspect the rendered DOM and verify the resolved entry element has
data-ctfl-entry-idand the relateddata-ctfl-*attributes.
Governance notes
Treat selected optimizations as targeting decisions. For public permutations, store and review the application rule that maps a route, segment, market, campaign, or locale to those selected optimizations. The SDK records and hydrates the selection; it does not decide whether that public permutation is allowed to be cached.
Do not put request profile state, request-derived selected optimizations, merge-tag output, or request-personalized HTML into a public shared cache. Cache raw Contentful baseline entries according to your application policy, then scope resolved output to the Optimization state that produced it.