Use this document to understand how the Optimization SDK Suite resolves a Contentful baseline entry to the entry variant selected for a visitor. It explains the runtime contract shared by the Core, Web, React Web, React Native, Node, iOS, and Android SDKs. Because rendered entries can also contain MergeTag entries, it also calls out where profile-dependent MergeTag value resolution differs from entry replacement.
For installation and package setup, use the relevant integration guide. For state propagation before resolution, see Core state management. For a broader explanation of Contentful and SDK Experience/event locale handling, see Locale handling in the Optimization SDK Suite.
Entry variant resolution is a local, non-networked decision. The resolver does not fetch Contentful entries, call the Experience API, evaluate audiences, allocate traffic, or mutate SDK state. It receives:
selectedOptimizations array from SDK-normalized Experience API data or from state maintained
by a stateful SDK.The Experience API owns profile evaluation and returns the selected experience and variant metadata. Contentful owns entry delivery and link resolution. The SDK joins those two data sets in memory and returns either the baseline entry or a resolved variant entry.
Applications still own Contentful client configuration, locale choice, cache policy, consent,
identity, routing, and component rendering policy. JavaScript runtimes can either receive a manual
baselineEntry or call the app-provided contentful.js client through SDK-managed fetching. After
those inputs exist, entry resolution provides the content decision for the current profile or
request.
Application provides a Contentful baseline entry or SDK fetches one by entry ID
-> Application or SDK emits an Experience event
-> SDK event result exposes data.selectedOptimizations
-> SDK resolver matches selectedOptimizations to entry.fields.nt_experiences
-> SDK returns baseline or linked variant entry for rendering
Raw Experience API responses name selected experiences in data.experiences. SDK event methods and
the API client normalize that field to data.selectedOptimizations before application code passes
it to entry resolution.
Entry resolution is available across the Optimization SDK Suite, but the public surface depends on the runtime:
| Runtime | Direct API | Component or native rendering API |
|---|---|---|
| Core | resolveOptimizedEntry() and managed fetchOptimizedEntry() |
None |
| Web | resolveOptimizedEntry() and managed fetchOptimizedEntry() |
ctfl-optimized-entry Web Component |
| React Web | useEntryResolver() and the underlying Web SDK method |
useOptimizedEntry() and OptimizedEntry |
| React Native | useEntryResolver(), useOptimizedEntry(), and the underlying React Native SDK method |
OptimizedEntry |
| Node | resolveOptimizedEntry() and managed fetchOptimizedEntry() |
None |
| iOS | OptimizationClient.resolveOptimizedEntry(baseline:selectedOptimizations:) |
SwiftUI OptimizedEntry; UIKit can call the client directly |
| Android | suspend OptimizationClient.resolveOptimizedEntry(...) |
Compose OptimizedEntry; XML Views OptimizedEntryView |
For Next.js App Router integrations, prefer the app-local bound OptimizedEntry returned by
createNextjsAppRouterOptimization() from @contentful/optimization-nextjs/app-router. In Server
Components it resolves through the Node SDK and server data; in Client Components the same app-local
name resolves through React Web. Pages Router integrations use
createNextjsPagesRouterOptimization() from @contentful/optimization-nextjs/pages-router for
client rendering, and routes that resolve entries manually can call getServerTrackingAttributes()
or ServerOptimizedEntry when they need SSR tracking attributes. ServerOptimizedEntry accepts
either manual baselineEntry and resolvedData props or the result returned by managed
fetchOptimizedEntry().
Usable entry selections depend on runtime state before the resolver runs:
selectedOptimizations comes from accepted Experience API
responses, configured defaults, or persisted defaults that a stateful runtime loads during
startup. Blocked events do not produce fresh selection data.allowedEventTypes decide which
Experience events can emit before consent is accepted. If a page, screen, identify, track, or
sticky view event is blocked, entry resolution uses the current in-memory selections, configured
defaults, persisted selections, or undefined.selectedOptimizations, happens only when persistence consent resolves to true. Configured
defaults can still seed one runtime instance without enabling durable storage.undefined, previous, or default selections until a successful response updates state.selectedOptimizations by applying audience or
variant overrides. The resolver still receives an ordinary selection array and follows the same
local matching rules.contentful.js client before
running the same resolver. Neither path retries Experience events, overrides consent policy, or
throws for personalization misses.The resolver expects standard single-locale Contentful Delivery API entry payloads. In that shape,
localized fields are direct field values, so optimization references are available as
entry.fields.nt_experiences and variant entries are available as
optimizationEntry.fields.nt_variants.
All-locale CDA payloads are incompatible with SDK entry resolution. contentful.js withAllLocales
and raw CDA locale=* return locale-keyed field maps instead of direct values, for example
fields.nt_experiences['en-US']. The SDK resolver intentionally handles one localized entry at a
time, so locale-keyed maps do not match the OptimizedEntry schema and resolution falls back to the
baseline entry.
JavaScript managed fetching is the preferred path when the application already owns a configured
contentful.js client. Configure the SDK with that client and a concrete single CDA locale:
JavaScript runtimes / TypeScript:
const appLocale = getAppLocale()
const optimization = new ContentfulOptimization({
clientId,
contentful: {
client: contentfulClient,
defaultQuery: { locale: appLocale },
},
environment,
locale: appLocale,
})
const { baselineEntry, entry } = await optimization.fetchOptimizedEntry(entryId, {
selectedOptimizations,
query: {
locale: appLocale,
},
})
Manual baselineEntry fetching remains supported and unchanged. Fetch the entry with a single CDA
locale and enough include depth for optimization links:
const optimization = new ContentfulOptimization({
clientId,
environment,
locale: appLocale,
})
const baselineEntry = await contentfulClient.getEntry(entryId, {
include: 10,
locale: appLocale,
})
Managed fetching merges contentful.defaultQuery, per-call query overrides, the SDK locale
fallback, and include: 10. Request-bound Node clients use forRequest({ locale }) as the locale
fallback for managed Contentful entry fetching. Managed fetching also keeps a small per-SDK-instance
entry cache by default. Set contentful.cache: false when the host application must own all
Contentful entry caching.
Use an application-owned Contentful locale for manual CDA entry fetches that feed entry resolution.
The SDK top-level locale or Node forRequest({ locale }) configures the SDK Experience/event
locale, but it does not modify manual Contentful client requests for you. For the full locale model,
see
Locale handling in the Optimization SDK Suite.
For entries that will be passed to the Optimization SDK resolver, use a concrete locale instead of the CDA wildcard locale:
JavaScript runtimes / TypeScript:
// These patterns return all locales and produce locale-keyed fields.
await contentfulClient.withAllLocales.getEntry(entryId, { include: 10 })
await fetch(`/spaces/${space}/environments/${environment}/entries/${entryId}?include=10&locale=*`)
A personalized entry starts as a regular Contentful entry with an nt_experiences field. That field
contains one or more linked nt_experience entries. The SDK treats the entry as optimized only when
fields.nt_experiences exists and the value conforms to the OptimizedEntry schema.
Resolved links matter. nt_experiences can contain Contentful links or full entries at the schema
level, but the resolver can match only full optimization entries. If Contentful returns unresolved
links, the resolver cannot inspect nt_experience_id, nt_config, or nt_variants, and resolution
falls back to the baseline entry. For optimized entries, request link depth sufficient to include
nt_experiences, their nt_config, and their nt_variants.
An optimization entry is a Contentful nt_experience entry attached to the baseline entry. It
contains the optimization metadata the resolver needs:
| Field | Role in entry resolution |
|---|---|
nt_experience_id |
Matches an item in selectedOptimizations. |
nt_type |
Identifies the entry as an experiment or personalization. The resolver treats both alike. |
nt_config |
Defines entry-replacement components. Traffic, distribution, and stickiness are upstream allocation metadata. |
nt_variants |
Contains the resolved Contentful entries that can replace the baseline entry. |
Selected variant entries must use the same content type as the baseline entry. If a matched variant ID points to a resolved entry with a different content type, the resolver ignores that entry and falls back to the baseline entry.
Components with type: "EntryReplacement" participate in entry resolution. Components with omitted
type are also treated as EntryReplacement components for backward-compatible Contentful
payloads. InlineVariable components are resolved through the Custom Flag and changes flow, not
through resolveOptimizedEntry().
SelectedOptimization is the Experience API selection record for an experience:
| Property | Role in entry resolution |
|---|---|
experienceId |
Matches optimizationEntry.fields.nt_experience_id. |
variantIndex |
Selects the baseline or one of the configured variants. |
variants |
Maps baseline entry IDs to variant entry IDs. The entry resolver does not read this map. |
sticky |
Returned as metadata for matched selections and used by tracking surfaces. |
The resolver uses experienceId and variantIndex. It returns the full SelectedOptimization as
metadata after it matches an attached optimization entry, including variantIndex: 0 baseline
selections and variant fallback cases that happen after that match. It does not return selected
metadata when no selection exists, the entry is not optimized, or no selected experience matches an
attached optimization entry.
Entry rendering can also involve MergeTag entries embedded in Rich Text. MergeTag helpers such as
getMergeTagValue() resolve against the current profile data returned by the Experience API. This
is separate from entry replacement: resolveOptimizedEntry() chooses the entry variant, while
MergeTag helpers resolve profile-dependent values inside rendered fields.
Stateful SDKs use their current SDK locale as the default Experience API locale and default event
context locale. Apps can change that value with setLocale() or, in React providers, by changing
the provider locale prop. Locale changes update SDK state only; fetch content or call page(),
screen(), or identify() again when the rendered data needs to refresh. Stateless server code
passes the application locale through forRequest({ locale: appLocale }).
That request locale is not a Contentful CDA locale. SDK top-level locale, stateful setLocale(),
and Node forRequest({ locale }) normalize valid explicit locale values and reject invalid explicit
values before the request. When the SDK locale is omitted, the Experience API locale query
parameter is omitted and the API uses its own default. Advanced low-level Experience API client
locale options are passed through as query parameters; invalid values on that low-level path can
fail API validation before a profile response is returned. If the tag is valid but a specific
location translation is not available, localized location values can remain untranslated.
The shared Core resolver follows one path for every SDK package:
selectedOptimizations is missing or
empty.nt_experiences field in the expected shape.entry.fields.nt_experiences to fully resolved optimization entries.nt_experience_id appears in selectedOptimizations.SelectedOptimization for that optimization entry.variantIndex: 0 as a baseline selection and return the baseline entry with
selectedOptimization metadata.EntryReplacement component, including components with omitted type, whose
baseline.id equals the baseline entry sys.id and whose baseline is not hidden.variantIndex - 1.id: "" (empty string), return an empty variant outcome: the
isEmptyVariant flag is set to true in ResolvedData, the baseline entry is returned as the
entry field (for tracking context), and selectedOptimization metadata is preserved so a
component view impression can still be emitted. No warning is logged. Renderers must suppress
visible content when isEmptyVariant is true.optimizationEntry.fields.nt_variants by the
selected variant ID, and confirm that the variant entry uses the baseline entry content type.selectedOptimization metadata when all checks pass.If steps 8, 11, or 12 fail after a SelectedOptimization has matched (not caused by an empty
variant), the resolver returns the baseline entry with the matched selectedOptimization metadata.
Resolution returns entry objects from the Contentful payload. Applications can cache raw Contentful
payloads across requests, but profile-resolved entries are request-local or session-local decisions.
Running resolution only chooses which entry to render. Stateful packages listen for optimization state changes around that decision, then choose again when their live-update rules allow it.
Assume the application fetched a baseline entry with sys.id: "hero-baseline". The entry includes a
resolved nt_experience entry with nt_experience_id: "exp-homepage-hero" and an
EntryReplacement component:
{
"baseline": { "id": "hero-baseline" },
"variants": [{ "id": "hero-variant-spring" }, { "id": "hero-variant-summer" }]
}
The same optimization entry includes hero-variant-spring and hero-variant-summer as resolved
entries in nt_variants. If the Experience API returns this selection:
{
"experienceId": "exp-homepage-hero",
"variantIndex": 2,
"variants": {
"hero-baseline": "hero-variant-summer"
},
"sticky": true
}
resolution matches experienceId to nt_experience_id, finds the component whose baseline.id is
hero-baseline, reads the second configured variant because variantIndex is 2, and returns the
resolved Contentful entry whose sys.id is hero-variant-summer.
variantIndex is baseline-aware:
variantIndex value |
Resolver behavior |
|---|---|
0 |
Return the baseline entry. |
1 |
Use the first item in the component variants array. |
2 |
Use the second item in the component variants array. |
| Out of range | Return the baseline entry. |
The index is not a JavaScript array index. The SDK interprets 0 as baseline and subtracts 1
before reading from the configured variant array.
Resolution is fail-soft. Invalid, incomplete, or unmatched data returns the baseline entry instead of throwing. The selected metadata result distinguishes a baseline selection, an empty variant, and a resolution miss:
| Condition | Entry result | isEmptyVariant |
selectedOptimization result |
|---|---|---|---|
No selectedOptimizations |
Baseline entry | — | undefined |
| Entry is not optimized | Baseline entry | — | undefined |
nt_experiences contains only unresolved links |
Baseline entry | — | undefined |
| No selected experience matches an attached entry | Baseline entry | — | undefined |
Selected variantIndex is 0 |
Baseline entry | — | Matched selection |
No relevant EntryReplacement component exists |
Baseline entry | — | Matched selection |
| Selected variant index is out of range | Baseline entry | — | Matched selection |
Selected variant has id: "" (empty variant) |
Baseline entry | true |
Matched selection |
Variant ID exists in config but not nt_variants |
Baseline entry | — | Matched selection |
| Variant entry is still an unresolved link | Baseline entry | — | Matched selection |
| Variant entry content type differs from baseline | Baseline entry | — | Matched selection |
The "empty variant" row is distinct from data errors. When a content author selects the Empty
variant option in the Personalization UI, the variant is stored in nt_config as
{ "id": "", "hidden": true }. An unfilled placeholder slot (a variant added or unlinked but not
yet configured) is stored as { "id": "", "hidden": false }. Both forms have id: "", and both
produce isEmptyVariant: true.
The SDK detects an empty variant by id === "" — not by the hidden flag. The hidden flag is not
a reliable signal because the Experience API strips it before runtime; it only survives in the
Contentful CDA nt_config payload that the resolver reads. Using id === "" is stable across both
data sources.
When isEmptyVariant is true, renderers must not display any content. They must still emit a
component view impression so the empty variant is measurable in Insights. The baseline entry is
returned in the entry field as tracking context only.
Consumers must not rely on exceptions to detect personalization misses. Render the baseline entry when no variant resolves; baseline fallback is expected behavior, not an error state.
A baseline entry can reference multiple nt_experience entries. The resolver picks the first
resolved optimization entry in nt_experiences whose nt_experience_id appears in
selectedOptimizations.
This makes Contentful link order meaningful when multiple selected experiences target the same
baseline entry. The resolver does not sort by optimization type, audience specificity, traffic, or
the order of selectedOptimizations.
selectedOptimization.variants remains part of the Experience API selection record, but the entry
resolver does not use that map to locate the replacement entry. Entry replacement comes from the
matched nt_config component and the resolved nt_variants entries in the Contentful payload.
The shared method is resolveOptimizedEntry(entry, selectedOptimizations?). It returns:
JavaScript runtimes / TypeScript:
{
entry: Entry
selectedOptimization?: SelectedOptimization
optimizationContextId?: string
}
optimizationContextId is an opaque SDK-owned value. Stateful SDK methods register it when a
matched optimization produces interaction context, so entry view, click, hover, or tap tracking can
enrich follow-up events without recomputing the resolution context. It is optional because stateless
resolution and unmatched fallbacks do not register interaction context.
The Node SDK is stateless. Pass the request-local selectedOptimizations from the data returned
by page(), identify(), screen(), track(), or sticky trackView().
Node server runtime / TypeScript:
const requestOptimization = optimization.forRequest({
consent: true,
profile,
})
const { accepted, data: optimizationData } = await requestOptimization.page({
properties: { path: req.path },
})
const { entry, selectedOptimization } = optimization.resolveOptimizedEntry(
baselineEntry,
accepted ? optimizationData?.selectedOptimizations : undefined,
)
For managed fetching, root stateless SDKs still need explicit selections for personalized results.
Request-bound forRequest() clients store the latest accepted Experience response and use its
selectedOptimizations when fetchOptimizedEntry(entryId) omits the option.
The Web, React Web, and React Native SDKs are stateful. If callers omit selectedOptimizations,
those SDKs resolve from their selectedOptimizations state. Passing an explicit array is still
useful for server-provided or request-local data because it avoids depending on ambient SDK state.
Provide optimization data before expecting optimized content. page(), identify(), screen(),
track(), and sticky trackView() can return the selected optimization data used by this method.
The iOS SDK exposes the same local boundary through
OptimizationClient.resolveOptimizedEntry(baseline:selectedOptimizations:). UIKit apps usually pass
client.selectedOptimizations during cell or view configuration. The method returns a
ResolvedOptimizedEntry containing the resolved entry and optional selectedOptimization
metadata and optimizationContextId, and returns the baseline unchanged when no selected
optimization matches the entry.
The Android SDK exposes the same local, non-networked boundary through
suspend OptimizationClient.resolveOptimizedEntry(baseline = ..., selectedOptimizations = ...).
Call it from a coroutine when resolving directly, or use Compose OptimizedEntry or XML Views
OptimizedEntryView so the adapter owns the coroutine call. Direct callers can pass
client.selectedOptimizations.value. The method returns a ResolvedOptimizedEntry containing the
resolved entry and optional selectedOptimization metadata and optimizationContextId, and
returns the baseline unchanged when no selected optimization matches the entry.
fetchOptimizedEntry(entryId) is the one-shot JavaScript path for callers that can await a managed
Contentful fetch and immediate variant resolution. It returns the fetched baselineEntry plus the
resolved entry and optimization metadata.
OptimizedEntrySourceController from @contentful/optimization-core/entry-source is the adapter
primitive for mounted components, hooks, custom elements, or other runtime wrappers that accept
either baselineEntry or entryId. It manages source changes, SDK readiness, loading and error
snapshots, stale fetch protection, and disconnect cleanup before resolution. It does not resolve or
render entries. After a snapshot contains baselineEntry, the adapter still calls
resolveOptimizedEntry(), renders the result, and attaches any tracking metadata for its runtime.
Use framework components or native view adapters when rendering is already inside a supported framework tree or native view adapter. These surfaces subscribe to SDK state, call the same shared resolver, and pass the resolved entry to your rendering code.
The Web SDK ctfl-optimized-entry custom element uses the same OptimizedEntryController as the
framework adapters. Assign the structured Contentful entry through the baselineEntry property, or
configure the SDK with contentful: { client } and set entry-id/entryId plus optional
entryQuery. baselineEntry takes precedence when both are set. Provide an SDK through either an
ancestor or explicit ctfl-optimization-root binding or the element's sdk property. The element
honors live-updates, track-clicks, track-hovers, and track-views attributes, applies
data-ctfl-* tracking attributes to the host, and emits ctfl-entry-loading,
ctfl-entry-resolved, and ctfl-entry-error events as entry fetching and controller state changes.
The Web SDK README owns setup details for defining the custom elements and assigning property-only
values.
React Web wraps resolution in useOptimizedEntry() and OptimizedEntry. OptimizedEntry:
sdk.states.selectedOptimizations.baselineEntry or managed entryId with optional entryQuery.undefined selected optimization set by default.liveUpdates is enabled globally, per component, or by the preview panel.data-ctfl-* attributes that the Web SDK entry tracking runtime can observe.OptimizedEntry components that use the same baseline entry ID to prevent duplicate
rendered branches.React Web also exposes useEntryResolver() for components that need manual resolution without the
OptimizedEntry wrapper.
React Native wraps resolution in useOptimizedEntry() and OptimizedEntry. OptimizedEntry:
baselineEntry or managed entryId with optional entryQuery.loadingFallback or null while a managed entry is fetching.errorFallback or null and calls onEntryError when managed entry fetching fails.states.selectedOptimizations only for optimized entries.liveUpdates is enabled globally, per component, or by the preview panel.selectedOptimization.React Native does not render DOM data attributes. It passes the resolved entry and optimization
metadata directly into its viewport and tap tracking hooks after a real entry exists.
useEntryResolver() remains the manual-only helper when a component already owns its baseline
entry.
SwiftUI uses the same resolver inside OptimizedEntry. The component passes non-optimized entries
through unchanged, resolves optimized entries from the client's selected optimizations, can lock to
the first resolved variant, can re-resolve when live updates are enabled, and can attach iOS view
and tap tracking using the baseline entry identity with selectedOptimization and
optimizationContextId metadata.
Android Compose uses the same resolver inside OptimizedEntry, and Android XML Views use it inside
OptimizedEntryView. Both adapters pass non-optimized entries through unchanged, resolve optimized
entries from the client's selected optimizations, can lock to the first resolved variant, can
re-resolve when live updates are enabled, and can attach Android view and tap tracking using the
baseline entry identity with selectedOptimization and optimizationContextId metadata.
Preview tooling changes selected variants by overriding variantIndex in selectedOptimizations.
The override path does not rewrite Contentful entries. After an override is merged into the selected
optimization signal, normal entry resolution runs again and picks the variant at the overridden
index.
Because the resolver uses variantIndex, preview overrides can append synthetic selected
optimization records with an empty variants map. This works as long as the matching
nt_experience entry and variant entries are present in the Contentful payload.
Use these concept docs for mechanics that often affect entry resolution:
profile, selectedOptimizations, and changes.Use these guides for SDK-specific integration paths:
Use these package READMEs when you need runtime-specific API orientation: