diff --git a/AGENTS.md b/AGENTS.md
index c483556c1..1aa02ead0 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -33,6 +33,16 @@ Repository-wide baseline. Child files add local constraints; the nearest child f
- Implementations consume local package tarballs from `pkgs/` after `pnpm build:pkgs` plus the
implementation install step.
+## Reference implementations
+
+- Treat `implementations/**` as first-class product artifacts: maintained SDK integration contracts,
+ customer-facing evidence, and required validation targets for the SDK surfaces they exercise.
+- Treat broken, stale, incomplete, or needlessly reduced reference implementations as SDK-suite
+ quality issues unless evidence shows the affected behavior is no longer supported.
+- Do not treat reference implementations as optional demos, disposable samples, or places to reduce
+ behavior for convenience. When public SDK behavior changes, identify affected reference
+ implementations before concluding validation.
+
## Code discipline
- Treat [`eslint.config.ts`](./eslint.config.ts) as an upfront design constraint.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index eaa219d73..de21913d2 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -85,14 +85,14 @@ pnpm version:pnpm
## Repository map
-| Path | Purpose |
-| -------------------- | ----------------------------------------------------------------------------------- |
-| `lib/` | Internal shared tooling and mock services, such as `build-tools` and `mocks` |
-| `packages/` | Workspace packages, including the published SDKs and framework layers |
-| `implementations/` | Reference applications used for integration testing, local demos, and E2E coverage |
-| `pkgs/` | Generated tarballs created by `pnpm build:pkgs`; implementations install from these |
-| `docs/` | Generated TypeDoc output |
-| `dist/`, `coverage/` | Generated build and test artifacts inside individual workspaces |
+| Path | Purpose |
+| -------------------- | ------------------------------------------------------------------------------------------ |
+| `lib/` | Internal shared tooling and mock services, such as `build-tools` and `mocks` |
+| `packages/` | Workspace packages, including the published SDKs and framework layers |
+| `implementations/` | Reference applications used for integration testing, validation evidence, and E2E coverage |
+| `pkgs/` | Generated tarballs created by `pnpm build:pkgs`; implementations install from these |
+| `docs/` | Generated TypeDoc output |
+| `dist/`, `coverage/` | Generated build and test artifacts inside individual workspaces |
The most important repository-specific mechanic is this:
@@ -335,7 +335,7 @@ category:
caveats, and links to guides, reference implementations, and generated API reference.
- Lower-level package READMEs explain the package's role in the SDK stack, who uses it directly,
common setup options where useful, and where exhaustive API details live.
-- Reference implementation READMEs stay procedural: what the implementation demonstrates,
+- Reference implementation READMEs stay procedural: what the implementation validates,
prerequisites, setup, run/test commands, environment notes, and related package links.
- Internal and placeholder READMEs stay short, explicit, and status-oriented.
diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md
index 081645b71..6e0d9d60c 100644
--- a/STYLE_GUIDE.md
+++ b/STYLE_GUIDE.md
@@ -116,6 +116,10 @@ Use the repository-standard product names and terms from `AGENTS.md`, including:
- reference implementation
- exact package names, such as `@contentful/optimization-web`
+When referring to in-repo apps under `implementations/`, use `reference implementation` or
+`reference app`. Do not use `demo`, `sample`, or `example` to describe their status; reserve
+`example` for code snippets or package-local harnesses when accurate.
+
Use Contentful product terms consistently when they apply:
- `app` - An HTML5 application that extends the functionality of the Contentful web app or
diff --git a/documentation/AGENTS.md b/documentation/AGENTS.md
index 1c558976c..61ad62a81 100644
--- a/documentation/AGENTS.md
+++ b/documentation/AGENTS.md
@@ -34,6 +34,9 @@ Applies to authored documentation under `documentation/`.
- In guides, do not place concept links in the opening before quick-start material unless the
concept is required for safe action. Put deeper mechanics links after the relevant step or in a
`## Learn more` section.
+- Treat reference implementations as maintained validation evidence and comparison targets for
+ supported integration paths. Do not frame them as optional examples, disposable samples, or
+ lower-stakes app code.
- Guides, concepts, and product documents may link to docs, package READMEs, implementation READMEs,
and generated reference docs, but not directly to source code, tests, generated outputs, or source
line numbers.
diff --git a/documentation/concepts/README.md b/documentation/concepts/README.md
index e0d7c52b3..bc301b0e6 100644
--- a/documentation/concepts/README.md
+++ b/documentation/concepts/README.md
@@ -29,8 +29,9 @@ they are not the first stop for installation or setup commands.
explains how SDK consent state, event allow-lists, blocked-event diagnostics, persistence, and
application-owned CMP policy work together to support consent-aware integrations.
- [Entry optimization and variant resolution](./entry-personalization-and-variant-resolution.md) -
- explains how the SDK resolves a Contentful baseline entry to the selected entry variant, including
- data model expectations, fallback behavior, resolution paths, and preview overrides.
+ explains how the SDK resolves a manual `baselineEntry` or SDK-managed Contentful fetch with
+ `contentful: { client }` to the selected entry variant, including single-locale CDA shape
+ expectations, fallback behavior, framework `entryId` paths, and preview overrides.
- [Locale handling in the Optimization SDK Suite](./locale-handling-in-the-optimization-sdk-suite.md) -
explains how application-owned Contentful locales differ from SDK Experience/event locales.
- [Interaction tracking in Web SDKs](./interaction-tracking-in-web-sdks.md) - explains how
diff --git a/documentation/concepts/consent-management-in-the-optimization-sdk-suite.md b/documentation/concepts/consent-management-in-the-optimization-sdk-suite.md
index 91d93c603..6f82e8f92 100644
--- a/documentation/concepts/consent-management-in-the-optimization-sdk-suite.md
+++ b/documentation/concepts/consent-management-in-the-optimization-sdk-suite.md
@@ -130,6 +130,10 @@ Account for these constraints before wiring lifecycle details:
- **Storage availability** - Platform storage is a durability layer, not the live source of truth.
If browser storage, AsyncStorage, UserDefaults, or SharedPreferences is unavailable or blocked,
design the application to continue from runtime state.
+- **Managed entry fetching** - SDK-managed entry fetching still uses the application-configured
+ `contentful.js` client and does not change consent ownership. Apply the same CMP, routing, locale,
+ and cache policy before choosing manual `baselineEntry` resolution or managed
+ `fetchOptimizedEntry()`.
- **Offline queue purge** - Withdrawing event consent with `consent(false)` purges queued SDK
Experience and Insights events. Blocked events are not replayed when consent later becomes true.
- **Preview mode** - Preview panels and preview overrides change live-update and preview behavior;
diff --git a/documentation/concepts/core-state-management.md b/documentation/concepts/core-state-management.md
index 68b2eaf57..40197664a 100644
--- a/documentation/concepts/core-state-management.md
+++ b/documentation/concepts/core-state-management.md
@@ -79,12 +79,14 @@ Swift or Kotlin published state in native runtimes, and request objects in Node/
### Key state definitions
**Locale state** is the SDK Experience API and event locale. It is not a Contentful Delivery API
-locale and it does not fetch localized entries for your application. In stateful JavaScript
-runtimes, `locale` is available as `sdk.locale` and `sdk.states.locale`; `setLocale()` updates the
-state signal, future Experience API requests, and default event context. React providers can update
-provider-owned SDK instances from their `locale` prop. iOS and Android expose the same value as
-native `locale` state from the bridge. Node and stateless runtimes bind locale per request with
-`forRequest({ locale })`.
+locale policy and changing it does not by itself refetch localized entries for your application. In
+stateful JavaScript runtimes, `locale` is available as `sdk.locale` and `sdk.states.locale`;
+`setLocale()` updates the state signal, future Experience API requests, and default event context.
+JavaScript managed fetching can use this value only as a fallback `getEntry()` query locale when no
+Contentful query locale is provided. Request-bound Node clients use `forRequest({ locale })` as that
+fallback for managed Contentful entry fetching. React providers can update provider-owned SDK
+instances from their `locale` prop. iOS and Android expose the same value as native `locale` state
+from the bridge. Node and stateless runtimes bind locale per request with `forRequest({ locale })`.
**`experienceRequestState`** tells you what happened to the most recent Experience API request. It
starts as `{ status: 'idle' }`, changes to `{ status: 'pending' }` when a request starts, changes to
@@ -98,6 +100,11 @@ available. `states.optimizationPossible` can be `true` before `states.selectedOp
contains variants. Use `states.canOptimize` when you need to know whether variant selection data is
available for entry resolution.
+**Selected optimization state** is the current Experience API selection array used by stateful entry
+resolution. In stateful JavaScript runtimes, `resolveOptimizedEntry(entry)` uses
+`states.selectedOptimizations.current` when explicit selections are omitted. In stateful Core and
+Web runtimes, `fetchOptimizedEntry(entryId)` follows the same default.
+
### Signals and observables
`CoreStateful` stores runtime state in [Preact Signals](https://github.com/preactjs/signals), a
@@ -156,11 +163,14 @@ still current and has not already produced an accepted event.
The SDK locale affects default Experience API requests and event context. It does not modify your
Contentful client, router, native localization, or server i18n state. Fetch Contentful entries with
the application-owned CDA locale, and pass SDK locale separately when Experience API responses and
-events need that locale.
+events need that locale. JavaScript managed fetching can use SDK locale only as the fallback
+Contentful `getEntry()` query locale when `contentful.defaultQuery` and the per-call query omit
+`locale`.
`setLocale(locale)` validates and normalizes explicit locale values. Invalid explicit values throw
without changing locale state. In stateful SDKs, changing locale affects future requests and events;
-it does not automatically refetch profile state or selected optimizations.
+it does not automatically refetch profile state, selected optimizations, or localized Contentful
+entries.
### Defaults, storage, offline, and preview state
@@ -275,6 +285,10 @@ instead of writing signal values directly. Think about state changes as a few fl
- **Experience-producing events** - `identify`, `page`, `screen`, `track`, and sticky `trackView`
send an Experience API request when consent or the allow-list permits it. A successful response
publishes profile, selected optimizations, changes, and request status in one state batch.
+- **SDK-managed entry fetching** - `fetchContentfulEntry(entryId)` and
+ `fetchOptimizedEntry(entryId)` call the configured consumer-owned `contentful.js` client. They do
+ not publish profile, selected optimizations, changes, or diagnostics events. Resolution reads
+ current selected optimization state only when options omit explicit selections.
- **Insights and diagnostics events** - `trackView`, `trackClick`, `trackHover`, and `trackFlagView`
can update `eventStream` when the event is accepted. Sticky `trackView` also uses the Experience
path before sending its paired Insights event.
@@ -288,6 +302,11 @@ Flag reads only produce accepted flag-view delivery when event consent is `true`
`allowedEventTypes` permits `flag` or `component`, and an active Optimization profile ID exists.
Reads before either condition is true do not update the accepted flag-view dedupe signature.
+The SDK-managed Contentful entry cache is separate from optimization state. It caches CDA entries
+returned by the configured `contentful.js` client and does not store profile or selected
+optimization data. Use `clearContentfulEntryCache()` when application locale, preview mode,
+environment, or cache policy changes make those cached entries invalid.
+
The package also exports raw `signals` and `signalFns` references for SDK layers and first-party
preview tooling. Those exports are not application consumer APIs. Application code must treat them
as read-only implementation details and use the methods, observables, defaults, and interceptors
diff --git a/documentation/concepts/entry-personalization-and-variant-resolution.md b/documentation/concepts/entry-personalization-and-variant-resolution.md
index be3cb69ee..6119fc84c 100644
--- a/documentation/concepts/entry-personalization-and-variant-resolution.md
+++ b/documentation/concepts/entry-personalization-and-variant-resolution.md
@@ -35,6 +35,7 @@ Contentful and SDK Experience/event locale handling, see
- [Multiple attached optimizations](#multiple-attached-optimizations)
- [Where resolution happens](#where-resolution-happens)
- [Resolve directly with SDK methods](#resolve-directly-with-sdk-methods)
+ - [Manage entry sources in custom adapters](#manage-entry-sources-in-custom-adapters)
- [Render with framework components](#render-with-framework-components)
- [Preview selected variants](#preview-selected-variants)
- [Related documentation](#related-documentation)
@@ -57,12 +58,14 @@ The Experience API owns profile evaluation and returns the selected experience a
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 consent, identity, routing, Contentful fetching, and component rendering
-policy. After those inputs exist, entry resolution provides the content decision for the current
-profile or request.
+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.
```text
-Application fetches Contentful baseline entry
+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
@@ -78,18 +81,19 @@ 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 | `CoreStateful.resolveOptimizedEntry()` and `CoreStateless.resolveOptimizedEntry()` | None |
-| Web | `ContentfulOptimization.resolveOptimizedEntry()` | `ctfl-optimized-entry` Web Component |
-| React Web | `useEntryResolver()` and the underlying Web SDK method | `useOptimizedEntry()` and `OptimizedEntry` |
-| React Native | `useEntryResolver()` and the underlying React Native SDK method | `OptimizedEntry` |
-| Node | `ContentfulOptimization.resolveOptimizedEntry()` | None |
-| iOS | `OptimizationClient.resolveOptimizedEntry(baseline:selectedOptimizations:)` | SwiftUI `OptimizedEntry`; UIKit can call the client directly |
-| Android | `suspend OptimizationClient.resolveOptimizedEntry(...)` | Compose `OptimizedEntry`; XML Views `OptimizedEntryView` |
+| 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` |
Next.js uses the Node server and React Web client surfaces, plus Next.js adapter components such as
-`ServerOptimizedEntry` for server-rendered entries.
+`ServerOptimizedEntry` for server-rendered entries. `ServerOptimizedEntry` accepts either manual
+`baselineEntry` and `resolvedData` props or the result returned by managed `fetchOptimizedEntry()`.
## Inputs and constraints
@@ -111,8 +115,10 @@ Usable entry selections depend on runtime state before the resolver runs:
- **Preview overrides** - Preview tooling mutates `selectedOptimizations` by applying audience or
variant overrides. The resolver still receives an ordinary selection array and follows the same
local matching rules.
-- **Resolution boundary** - Entry resolution stays local and fail-soft. It does not retry events,
- fetch Contentful entries, override consent policy, or throw for personalization misses.
+- **Resolution boundary** - Synchronous entry resolution stays local and fail-soft. SDK-managed
+ fetching is an additive JavaScript path that calls the configured `contentful.js` client before
+ running the same resolver. Neither path retries Experience events, overrides consent policy, or
+ throws for personalization misses.
## Single-locale CDA entry contract
@@ -127,13 +133,36 @@ and raw CDA `locale=*` return locale-keyed field maps instead of direct values,
time, so locale-keyed maps do not match the `OptimizedEntry` schema and resolution falls back to the
baseline entry.
-Fetch the entry with a single CDA locale and enough include depth for optimization links:
+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:
```ts
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:
+
+```ts
const optimization = new ContentfulOptimization({
clientId,
environment,
@@ -146,9 +175,16 @@ const baselineEntry = await contentfulClient.getEntry(entryId, {
})
```
-Use an application-owned Contentful locale for 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 Contentful client requests for you. For the full locale model, see
+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](./locale-handling-in-the-optimization-sdk-suite.md).
For entries that will be passed to the Optimization SDK resolver, use a concrete locale instead of
@@ -386,6 +422,10 @@ const { entry, selectedOptimization } = optimization.resolveOptimizedEntry(
)
```
+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.
@@ -408,6 +448,19 @@ Call it from a coroutine when resolving directly, or use Compose `OptimizedEntry
resolved `entry` and optional `selectedOptimization` metadata and `optimizationContextId`, and
returns the baseline unchanged when no selected optimization matches the entry.
+### Manage entry sources in custom adapters
+
+`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.
+
### Render with framework components
Use framework components or native view adapters when rendering is already inside a supported
@@ -415,16 +468,20 @@ framework tree or native view adapter. These surfaces subscribe to SDK state, ca
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, and
-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` and `ctfl-entry-resolved` events as the controller state changes. The Web SDK
-README owns setup details for defining the custom elements and assigning property-only values.
+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`:
- Subscribes to `sdk.states.selectedOptimizations`.
+- Accepts either `baselineEntry` or managed `entryId` with optional `entryQuery`.
- Locks to the first non-`undefined` selected optimization set by default.
- Re-resolves when `liveUpdates` is enabled globally, per component, or by the preview panel.
- Shows a loading fallback for optimized entries until optimization state is available.
@@ -436,9 +493,12 @@ React Web wraps resolution in `useOptimizedEntry()` and `OptimizedEntry`. `Optim
React Web also exposes `useEntryResolver()` for components that need manual resolution without the
`OptimizedEntry` wrapper.
-React Native uses the same resolver inside its `OptimizedEntry` component. The component:
+React Native wraps resolution in `useOptimizedEntry()` and `OptimizedEntry`. `OptimizedEntry`:
- Passes non-optimized entries through unchanged.
+- Accepts either `baselineEntry` or managed `entryId` with optional `entryQuery`.
+- Renders `loadingFallback` or `null` while a managed entry is fetching.
+- Renders `errorFallback` or `null` and calls `onEntryError` when managed entry fetching fails.
- Subscribes to `states.selectedOptimizations` only for optimized entries.
- Locks to the first selected optimization set by default.
- Re-resolves when `liveUpdates` is enabled globally, per component, or by the preview panel.
@@ -446,7 +506,9 @@ React Native uses the same resolver inside its `OptimizedEntry` component. The c
- Sends view and tap tracking metadata from the resolved entry and `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.
+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
@@ -496,6 +558,7 @@ Use these guides for SDK-specific integration paths:
- [Integrating the Optimization React Web SDK in a React app](../guides/integrating-the-react-web-sdk-in-a-react-app.md)
- [Integrating the React Native SDK in a React Native app](../guides/integrating-the-react-native-sdk-in-a-react-native-app.md)
- [Integrating the Node SDK in a Node app](../guides/integrating-the-node-sdk-in-a-node-app.md)
+- [Building a custom JavaScript Optimization adapter](../guides/building-a-custom-javascript-optimization-adapter.md)
- [Integrating the Optimization iOS SDK in a SwiftUI app](../guides/integrating-the-optimization-ios-sdk-in-a-swiftui-app.md)
- [Integrating the Optimization iOS SDK in a UIKit app](../guides/integrating-the-optimization-ios-sdk-in-a-uikit-app.md)
- [Integrating the Optimization Android SDK in a Jetpack Compose app](../guides/integrating-the-optimization-android-sdk-in-a-compose-app.md)
diff --git a/documentation/concepts/interaction-tracking-in-node-and-stateless-environments.md b/documentation/concepts/interaction-tracking-in-node-and-stateless-environments.md
index b7b8e6f73..75c7e1484 100644
--- a/documentation/concepts/interaction-tracking-in-node-and-stateless-environments.md
+++ b/documentation/concepts/interaction-tracking-in-node-and-stateless-environments.md
@@ -53,7 +53,7 @@ interaction decides which facts are available.
| Path | Runtime responsibility | Use when |
| ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
-| `@contentful/optimization-node` | Bind request consent, locale, profile, and page context; call Experience API methods; resolve entries; emit server-known events. | Server rendering owns personalization, and the event is a request fact or server-observed business action. |
+| `@contentful/optimization-node` | Bind request consent, locale, profile, and page context; call Experience API methods; resolve entries with app-fetched `baselineEntry` or request-bound `fetchOptimizedEntry()`; emit server-known events. | Server rendering owns personalization, and the event is a request fact or server-observed business action. |
| `@contentful/optimization-web` | Own browser consent state, profile state, storage, automatic DOM observation, browser queues, and Insights delivery. | Non-React or custom browser code needs view, click, hover, route, or manual element tracking after HTML reaches the page. |
| `@contentful/optimization-react-web` | Wrap the Web SDK with React browser providers, hooks, router trackers, and `OptimizedEntry` from `@contentful/optimization-react-web`. | React browser apps need framework-owned state, route page tracking, entry wrappers, or browser-side entry personalization. |
| `@contentful/optimization-nextjs` | Own Next.js adapter surfaces: server helpers and `ServerOptimizedEntry` from `@contentful/optimization-nextjs/server`, request helpers from `@contentful/optimization-nextjs/request-handler`, tracking helpers from `@contentful/optimization-nextjs/tracking-attributes`, and client wrappers from `@contentful/optimization-nextjs/client`. | Next.js apps need server-owned personalization, request and cookie helpers, SSR tracking attributes, and client tracking boundaries. |
@@ -212,6 +212,15 @@ if (!pageResponse) {
}
```
+When the Node SDK is configured with a consumer-owned `contentful.js` client, prefer
+`requestOptimization.fetchOptimizedEntry(entryId)` after an accepted Experience call. The
+request-bound client uses the latest `selectedOptimizations` when they are omitted. Manual
+`baselineEntry` plus `resolveOptimizedEntry()` remains supported when the application owns CDA
+fetching or caching directly.
+
+Both managed `fetchOptimizedEntry()` and manual `resolveOptimizedEntry()` expect single-locale CDA
+entry shapes. Avoid `withAllLocales` and `locale=*` for optimization surfaces.
+
Use server-side `track()` for server-known business events:
```ts
diff --git a/documentation/concepts/interaction-tracking-in-web-sdks.md b/documentation/concepts/interaction-tracking-in-web-sdks.md
index 43ef77b26..f16e6aab8 100644
--- a/documentation/concepts/interaction-tracking-in-web-sdks.md
+++ b/documentation/concepts/interaction-tracking-in-web-sdks.md
@@ -67,8 +67,10 @@ allowed, which event type it becomes, and which queue receives it.
| `@contentful/optimization-web` | Initializes Core for a browser runtime. Persists consent and, when persistence consent permits it, profile data, selected optimizations, and anonymous IDs. Discovers tracked DOM elements and observes interactions. |
| `@contentful/optimization-react-web` | Creates and tears down the Web SDK instance, resolves entries in React, emits `data-ctfl-*` attributes, exposes the SDK instance, and emits router-driven `page()` calls. |
-The application still owns Contentful fetching, rendering policy, consent UX, identity policy, route
-ownership, and any business event taxonomy passed to `track()`.
+The application owns rendering policy, consent UX, identity policy, route ownership, and any
+business event taxonomy passed to `track()`. Contentful entry fetching is application-owned unless
+the SDK is explicitly configured with a consumer-owned `contentful.js` client for managed entry
+fetching.
## Runtime prerequisites and defaults
@@ -376,7 +378,9 @@ SDK configuration values pass through to the underlying instance.
`OptimizedEntry` does three tracking-related things:
-- Resolves the baseline entry with `useOptimizedEntry()`.
+- Resolves a provided baseline entry, or fetches the baseline first when `OptimizedEntry` or
+ `useOptimizedEntry()` receives an `entryId` and the SDK is configured with a `contentful.js`
+ client.
- Renders a wrapper element with `display: contents`.
- Adds tracking attributes for the resolved entry when resolved content is ready.
@@ -397,6 +401,9 @@ The wrapper receives:
`data-ctfl-duplication-scope` is emitted by React Web for optimization metadata, but the Web SDK
entry interaction payload does not use it.
+Managed entry fetching expects the same single-locale CDA entry shape as manual `baselineEntry`
+resolution. Do not use `withAllLocales` or `locale=*` for Web or React Web optimization surfaces.
+
During loading, `OptimizedEntry` does not emit resolved entry tracking attributes. Loading UI is
therefore not tracked as the resolved Contentful entry. If `children` is a direct `ReactNode`
instead of a render prop, the wrapper still receives tracking attributes, but the child content does
@@ -406,6 +413,12 @@ not change based on the resolved entry.
a component uses `useOptimizedEntry()` directly, it must either render the `data-ctfl-*` attributes
itself or use `sdk.tracking.enableElement(...)`.
+> [!IMPORTANT]
+>
+> `OptimizedEntrySourceController` does not emit Web tracking attributes. Custom Web adapters that
+> use it must render `data-ctfl-*` attributes after resolution or call
+> `optimization.tracking.enableElement(...)` with equivalent data.
+
React Web router adapters emit `page()` calls when supported routers change route. They are page
event helpers, not entry interaction detectors. Entry views, clicks, and hovers still come from the
Web SDK runtime.
@@ -466,7 +479,8 @@ provider children can emit router `page()` events or entry interactions.
The Web SDKs do not own every part of tracking:
-- They do not fetch Contentful entries.
+- They do not infer Contentful entries from tracking metadata. Managed entry fetching requires an
+ explicitly configured `contentful.js` client.
- They do not decide whether a user has granted consent.
- They do not infer a browser view from server rendering alone.
- They do not make non-clickable markup clickable.
@@ -494,5 +508,7 @@ application.
Step-by-step browser integration flow.
- [Integrating the Optimization React Web SDK in a React app](../guides/integrating-the-react-web-sdk-in-a-react-app.md) -
Step-by-step React integration flow.
+- [Building a custom JavaScript Optimization adapter](../guides/building-a-custom-javascript-optimization-adapter.md) -
+ Low-level entry-source lifecycle guidance for custom adapter authors.
- [Forwarding Optimization SDK context to analytics and tag-management tools](../guides/forwarding-optimization-sdk-context-to-analytics-and-tag-management-tools.md) -
Consent-aware forwarding, sticky-view dedupe, and Custom Flag analytics handoff.
diff --git a/documentation/concepts/locale-handling-in-the-optimization-sdk-suite.md b/documentation/concepts/locale-handling-in-the-optimization-sdk-suite.md
index 0960fa7d6..f52a59ef3 100644
--- a/documentation/concepts/locale-handling-in-the-optimization-sdk-suite.md
+++ b/documentation/concepts/locale-handling-in-the-optimization-sdk-suite.md
@@ -6,11 +6,12 @@ title: Locale handling in the Optimization SDK Suite
Use this document to keep the application Contentful locale separate from the SDK Experience/event
locale across Web, React Web, Next.js, Node, React Native, iOS, and Android applications. For
-app-owned content fetching and entry resolution, the SDKs do not resolve Contentful locales, wrap
+app-owned content fetching and entry resolution, the SDKs do not resolve Contentful locales, create
Contentful Delivery API clients, or infer browser, device, or request locales. Applications choose
-their own locale from routing, i18n, native state, or request logic and pass it to each system that
-needs it. Preview and debug tooling is separate: preview-panel APIs can use Contentful clients to
-load Optimization definitions, but they do not choose or fetch locales for application content.
+their own locale from routing, i18n, native state, or request logic and pass it to manual Contentful
+calls or SDK-managed entry fetching. Preview and debug tooling is separate: preview-panel APIs can
+use Contentful clients to load Optimization definitions, but they do not choose locales for
+application content.
For entry replacement mechanics, see
[Entry optimization and variant resolution](./entry-personalization-and-variant-resolution.md). For
@@ -63,23 +64,48 @@ locale is supported by Contentful.
## Application Contentful locale
-Applications fetch Contentful entries directly. Choose an `appLocale` with application-owned logic,
-then pass it to CDA or CPA calls.
+Choose an `appLocale` with application-owned logic, then pass it to CDA or CPA calls. JavaScript
+managed fetching uses the application-owned `contentful.js` client from `contentful: { client }`;
+the SDK does not create clients, discover Contentful locales, infer browser or request locales, or
+own locale policy.
-Contentful fetch, JavaScript runtimes (TypeScript):
+SDK-managed Contentful fetch, JavaScript runtimes (TypeScript):
```ts
const appLocale = getAppLocale()
+const optimization = new ContentfulOptimization({
+ clientId,
+ contentful: {
+ client: contentfulClient,
+ defaultQuery: { locale: appLocale },
+ },
+ locale: appLocale,
+})
+
+const entry = await optimization.fetchContentfulEntry(entryId, {
+ locale: appLocale,
+})
+```
+
+Per-call `entryQuery` or `query` values override `contentful.defaultQuery`. If no Contentful query
+locale is provided, managed fetching falls back to the SDK `locale` before calling
+`contentful.js getEntry()`. Request-bound Node clients use `forRequest({ locale })` as that
+fallback. Use a concrete locale such as `en-US`; do not use `withAllLocales` or `locale=*` for
+entries that the SDK will resolve.
+
+Manual Contentful fetch, JavaScript runtimes (TypeScript):
+
+```ts
const entry = await contentfulClient.getEntry(entryId, {
include: 10,
locale: appLocale,
})
```
-Do this anywhere Contentful content is fetched: browser data loaders, React hooks, server routes,
-React Native services, and native app content clients. If the app omits `locale`, Contentful uses
-the space default locale.
+Pass the same `appLocale` anywhere Contentful content is fetched: browser data loaders, React hooks,
+server routes, React Native services, and native app content clients. If the app omits `locale`,
+Contentful uses the space default locale.
Use the same `appLocale` in cache keys when localized content can differ.
@@ -136,8 +162,10 @@ On iOS and Android, call `setLocale` only after the client has initialized; set
through `OptimizationConfig` before mounting or initializing.
`setLocale(locale)` validates and normalizes the SDK Experience/event locale. It does not refetch
-Contentful content, update routes, or clear application caches. Application code must refetch
-Contentful entries with its chosen Contentful locale.
+Contentful content, update routes, or clear application caches. JavaScript managed fetching can use
+the SDK locale only as the fallback `getEntry()` query locale when neither `contentful.defaultQuery`
+nor a per-call query provides one. Application code must refetch Contentful entries with its chosen
+Contentful locale.
Web or React Web client runtime (TypeScript):
@@ -221,17 +249,30 @@ const [entry, data] = await Promise.all([
`forRequest({ locale })` sets the request-bound Experience API locale and default event context
locale. If both `locale` and `experienceOptions.locale` are supplied, `locale` wins. Use
`experienceOptions.locale` only as an advanced low-level pass-through when `locale` is not supplied.
+When a Node SDK is configured with `contentful: { client }`, root `fetchOptimizedEntry(entryId)`
+needs explicit `selectedOptimizations` for personalized results. A request-bound `forRequest()`
+client uses the latest accepted Experience response selections by default when
+`fetchOptimizedEntry(entryId)` omits `selectedOptimizations`. It also uses the request `locale` as
+the managed Contentful query locale when neither `contentful.defaultQuery` nor the per-call query
+sets `locale`.
## Entry resolution and localized Contentful content
Entry resolution expects one localized view of a baseline entry and linked optimization entries.
Pass direct single-locale field values to the runtime-specific entry resolution surface:
-- Web and Node `resolveOptimizedEntry()`.
-- React Web and React Native `OptimizedEntry` and `useEntryResolver()`.
-- React Web and Next.js client `useOptimizedEntry()`.
-- Next.js server `resolveOptimizedEntry()`; pass the baseline entry and returned `ResolvedData` to
- `ServerOptimizedEntry` when server-rendered tracking attributes are needed.
+- Core, Web, and Node `fetchContentfulEntry()` and `fetchOptimizedEntry()` for JavaScript
+ SDK-managed fetching through an app-owned `contentful.js` client.
+- Web and Node `resolveOptimizedEntry()` for manual baseline entries.
+- React Web `OptimizedEntry` and `useOptimizedEntry()` with either `baselineEntry` or managed
+ `entryId` plus optional `entryQuery`.
+- Web Component `ctfl-optimized-entry` with either `baselineEntry` or managed `entry-id`/`entryId`
+ plus optional `entryQuery`.
+- Next.js server `resolveOptimizedEntry()` or managed `fetchOptimizedEntry()`; pass manual
+ `baselineEntry` and `resolvedData` props or the managed result to `ServerOptimizedEntry` when
+ server-rendered tracking attributes are needed.
+- React Native `OptimizedEntry` and `useOptimizedEntry()` with either `baselineEntry` or managed
+ `entryId` plus optional `entryQuery`; `useEntryResolver()` remains manual-only.
- iOS `OptimizationClient.resolveOptimizedEntry(baseline:selectedOptimizations:)` and SwiftUI
`OptimizedEntry(entry:)`.
- Android `OptimizationClient.resolveOptimizedEntry(...)`, Compose `OptimizedEntry(entry:)`, and XML
@@ -241,7 +282,8 @@ Do not pass all-locale CDA responses from `withAllLocales` or `locale=*`.
The SDK does not mutate application Contentful clients or infer when a content refetch is needed.
When route or language state changes, the application must update SDK locale state, refetch
-Contentful content with the app locale, and invalidate app caches as needed.
+Contentful content with the app locale, clear SDK-managed Contentful entry cache entries when those
+cached CDA results are no longer valid, and invalidate app caches as needed.
## Application responsibilities
@@ -249,7 +291,8 @@ Applications own:
- Choosing the application Contentful locale from routes, request context, i18n state, or native app
state.
-- Passing the Contentful locale to CDA and CPA requests.
+- Passing the Contentful locale to manual CDA and CPA requests or SDK-managed `contentful.js`
+ fetching.
- Passing the SDK Experience/event locale through top-level SDK `locale`, provider `locale`, Next.js
`getNextjsServerOptimizationData({ locale })`, Next.js ESR
`getNextjsEsrOptimizationData({ locale })`, native config `locale`, native `setLocale`, or Node
diff --git a/documentation/concepts/profile-synchronization-between-client-and-server.md b/documentation/concepts/profile-synchronization-between-client-and-server.md
index 3099e2b7b..c5a5bf205 100644
--- a/documentation/concepts/profile-synchronization-between-client-and-server.md
+++ b/documentation/concepts/profile-synchronization-between-client-and-server.md
@@ -117,12 +117,12 @@ For state shape and observable state mechanics, see
The server, browser, and API each own different parts of the profile lifecycle:
-| Runtime or layer | Owns | Does not own |
-| ----------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
-| **Experience API** | Profile creation, profile updates, audience evaluation, selected optimizations, and changes. | Application consent policy, cookie settings, Contentful fetching, rendering, or response caching. |
-| **Node SDK** | Stateless Experience and Insights calls using request-scoped profile IDs and request options. | Cookies, sessions, long-lived profile state, browser storage, or consent state. |
-| **Web SDK and React Web SDK** | Browser state, consent state, localStorage caches, readable anonymous-ID cookie, and queues. | Server sessions, server response caching, server-rendered hydration data, or Contentful fetching. |
-| **Application** | Consent policy, identity policy, cookie attributes, request context, and cache boundaries. | The internal profile aggregation rules of the Experience API. |
+| Runtime or layer | Owns | Does not own |
+| ----------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| **Experience API** | Profile creation, profile updates, audience evaluation, selected optimizations, and changes. | Application consent policy, cookie settings, Contentful fetching, rendering, or response caching. |
+| **Node SDK** | Stateless Experience and Insights calls using request-scoped profile IDs and request options. | Cookies, sessions, long-lived profile state, browser storage, or consent state. |
+| **Web SDK and React Web SDK** | Browser state, consent state, localStorage caches, readable anonymous-ID cookie, and queues. | Server sessions, server response caching, server-rendered hydration data, or Contentful entry fetching unless explicitly configured with a consumer-owned `contentful.js` client for managed fetching. |
+| **Application** | Consent policy, identity policy, cookie attributes, request context, and cache boundaries. | The internal profile aggregation rules of the Experience API. |
The React Web SDK uses the Web SDK under its providers and hooks, so profile synchronization follows
the same browser mechanics.
@@ -144,7 +144,7 @@ same evaluated data before its first client-side Experience response.
| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Server owns the first render** | The server renders selected variants and profile-derived values, and the client can wait for fresh SDK data before re-resolving. | Persist `ctfl-opt-aid` when allowed, and prevent stale browser caches from driving visible personalized content before a later Experience response. |
| **Server bootstraps the browser** | The client must continue from the same evaluated data before its first browser Experience response. | For direct Web SDK initialization, serialize the server's `profile`, `selectedOptimizations`, and `changes` into `defaults`. For React Web and Next.js direct provider handoff, pass the server `OptimizationData` through `serverOptimizationState`. For Next.js page-level handoff, render `NextjsOptimizationState` under existing SDK context. |
-| **Browser owns personalization** | The server can render baseline or loading output while the client resolves personalization after hydration. | Persist `ctfl-opt-aid` when allowed, then let the Web SDK call `page()` and resolve entries after selected optimizations are available. |
+| **Browser owns personalization** | The server can render baseline or loading output while the client resolves personalization after hydration. | Persist `ctfl-opt-aid` when allowed, then let the Web SDK call `page()` and resolve entries in the app layer, or use SDK-managed fetching only when configured with a `contentful.js` client, after selected optimizations are available. |
Direct Web SDK bootstrapping must use the same `OptimizationData` response that drove the server
render:
@@ -529,6 +529,8 @@ Use this checklist when implementing a hybrid Node and browser profile flow:
before the first browser Experience response.
- Clear both browser state and server persistence when consent revocation must end profile
continuity.
+- Use single-locale CDA entry payloads for both manual `baselineEntry` resolution and SDK-managed
+ `fetchOptimizedEntry()`; avoid `withAllLocales` and `locale=*`.
- Cache raw Contentful delivery payloads, not profile-evaluated SDK responses or personalized HTML
unless the cache key varies on the full personalization context.
diff --git a/documentation/guides/AGENTS.md b/documentation/guides/AGENTS.md
index 1c6d3b390..e76dd2cda 100644
--- a/documentation/guides/AGENTS.md
+++ b/documentation/guides/AGENTS.md
@@ -362,8 +362,10 @@ When supported by the SDK/runtime, cover:
## Reference implementations
-- Link only to monorepo reference implementation READMEs, not external demos or implementation
- source files.
+- Link only to monorepo reference implementation READMEs, not external demos, sample apps, or
+ implementation source files.
+- Frame reference implementations as maintained comparison and validation targets for SDK behavior.
+ Do not present them as optional examples or lower-stakes sample code.
- Mention relevant implementations briefly near the top only when they clarify the quick start or
first required setup path; otherwise, expand links in
`Reference implementations to compare against`.
diff --git a/documentation/guides/README.md b/documentation/guides/README.md
index da0273a2d..d663baec0 100644
--- a/documentation/guides/README.md
+++ b/documentation/guides/README.md
@@ -12,6 +12,7 @@ children:
- ./integrating-the-optimization-ios-sdk-in-a-uikit-app.md
- ./integrating-the-optimization-android-sdk-in-a-compose-app.md
- ./integrating-the-optimization-android-sdk-in-a-views-app.md
+ - ./building-a-custom-javascript-optimization-adapter.md
- ./forwarding-optimization-sdk-context-to-analytics-and-tag-management-tools.md
---
@@ -53,6 +54,9 @@ Native iOS and Android guides route to pre-release alpha surfaces.
## Supplemental guides
+- [Building a custom JavaScript Optimization adapter](./building-a-custom-javascript-optimization-adapter.md) -
+ Build a low-level adapter only when no official SDK package fits your JavaScript runtime or
+ framework.
- [Forwarding Optimization SDK context to analytics and tag-management tools](./forwarding-optimization-sdk-context-to-analytics-and-tag-management-tools.md) -
Forward optimization context to analytics, tag-management, customer-data, or product-analytics
tools after SDK integration.
diff --git a/documentation/guides/building-a-custom-javascript-optimization-adapter.md b/documentation/guides/building-a-custom-javascript-optimization-adapter.md
new file mode 100644
index 000000000..32effbafc
--- /dev/null
+++ b/documentation/guides/building-a-custom-javascript-optimization-adapter.md
@@ -0,0 +1,208 @@
+---
+title: Building a custom JavaScript Optimization adapter
+---
+
+# Building a custom JavaScript Optimization adapter
+
+Use this guide when you are building a JavaScript runtime or framework adapter and no official
+Optimization SDK package fits that surface.
+
+## Do you need this?
+
+Do not build a custom adapter when Web, React Web, Next.js, Node, React Native, iOS, or Android
+matches the application runtime. Those packages own rendering conventions, tracking integration,
+consent defaults, route or screen lifecycle, preview behavior, and platform cleanup.
+
+Build here only when you own an adapter layer that must compose Core primitives into a runtime the
+Optimization SDK Suite does not already cover.
+
+## Quick start
+
+**Adapt this to your use case:**
+
+```ts
+import { CoreStateful } from '@contentful/optimization-core'
+import { OptimizedEntrySourceController } from '@contentful/optimization-core/entry-source'
+
+const optimization = new CoreStateful({
+ clientId,
+ environment,
+ locale: appLocale,
+ contentful: {
+ client: contentfulClient,
+ defaultQuery: { locale: appLocale },
+ },
+})
+
+const source = new OptimizedEntrySourceController()
+
+source.setSnapshotListener((snapshot) => {
+ if (snapshot.isLoading) {
+ renderLoading()
+ return
+ }
+
+ if (snapshot.error) {
+ renderError(snapshot.error)
+ return
+ }
+
+ if (!snapshot.baselineEntry) return
+
+ const resolved = optimization.resolveOptimizedEntry(snapshot.baselineEntry)
+ renderResolvedEntry(resolved.entry, resolved.selectedOptimization)
+})
+
+source.updateOptions({
+ entryId: 'hero-entry',
+ entryQuery: { locale: appLocale },
+ sdk: optimization,
+ isSdkStateReady: true,
+})
+```
+
+Verify that the adapter renders a loading state, then renders either the selected variant or the
+baseline fallback for one entry.
+
+
+ Table of Contents
+
+
+- [Default recipe](#default-recipe)
+ - [Configure Core](#configure-core)
+ - [Drive source controller lifecycle](#drive-source-controller-lifecycle)
+ - [Resolve and render](#resolve-and-render)
+ - [Handle selected optimizations](#handle-selected-optimizations)
+- [Runtime or vendor variants](#runtime-or-vendor-variants)
+ - [Browser adapters](#browser-adapters)
+ - [Core-only non-Web adapters](#core-only-non-web-adapters)
+ - [One-shot server paths](#one-shot-server-paths)
+- [Validate the integration](#validate-the-integration)
+- [Governance notes](#governance-notes)
+- [Related guides and concepts](#related-guides-and-concepts)
+
+
+
+
+## Default recipe
+
+### Configure Core
+
+Create the Contentful Delivery client in your application or adapter host, then pass it to Core:
+
+```ts
+const optimization = new CoreStateful({
+ clientId,
+ environment,
+ locale: appLocale,
+ contentful: {
+ client: contentfulClient,
+ defaultQuery: { locale: appLocale },
+ cache: { maxEntries: 100, ttlMs: 300_000 },
+ },
+})
+```
+
+`contentful.defaultQuery` applies to every SDK-managed `getEntry()` call. Set
+`contentful.cache: false` only when the host application must own all entry caching.
+
+### Drive source controller lifecycle
+
+Create one `OptimizedEntrySourceController` for each adapter instance that owns one entry source.
+Call `updateOptions()` whenever adapter inputs change:
+
+- Pass `baselineEntry` when the host already fetched the entry. It takes precedence over `entryId`.
+- Pass `entryId`, optional `entryQuery`, an SDK with `fetchContentfulEntry()`, and
+ `isSdkStateReady: true` when the adapter wants Core-managed fetching.
+- Use `setSnapshotListener()` to schedule renders from loading, error, or `baselineEntry` snapshots.
+- Use `getSnapshot()` when the runtime needs a synchronous current value.
+- Call `disconnect()` when the adapter unmounts or disposes.
+
+The controller keys managed fetches by `entryId + entryQuery`, waits in loading state until the SDK
+is ready, ignores stale fetch results after source changes, and clears in-flight ownership on
+disconnect. `createOptimizedEntryLoadingEntry(entryId)` is available when a framework needs a stable
+placeholder `Entry` shape during loading.
+
+### Resolve and render
+
+The source controller only produces a baseline entry. After a snapshot contains `baselineEntry`,
+call `resolveOptimizedEntry()` and render the returned `entry`:
+
+```ts
+const { entry, selectedOptimization, optimizationContextId } = optimization.resolveOptimizedEntry(
+ snapshot.baselineEntry,
+)
+```
+
+Render the baseline entry when no variant resolves. Missing selections, unmatched optimization
+metadata, unresolved Contentful links, and out-of-range variants are fallback cases, not adapter
+errors.
+
+### Handle selected optimizations
+
+Stateful Core can resolve from its current `selectedOptimizations` state when you omit the second
+argument. Emit a profile-producing event such as `page()` or `identify()` before expecting fresh
+personalization, and subscribe to `states.selectedOptimizations` when the adapter supports live
+updates.
+
+Stateless Core needs request-local selections. Use a request-bound `forRequest()` client when
+available; it stores the latest accepted Experience response for that request. Root stateless
+callers pass explicit `selectedOptimizations` to `resolveOptimizedEntry()` or
+`fetchOptimizedEntry()`.
+
+## Runtime or vendor variants
+
+### Browser adapters
+
+For browser adapters that build on `@contentful/optimization-web`, prefer
+`@contentful/optimization-web/presentation` when you also need Web presentation helpers such as
+tracking attribute generation. For Core-only browser adapters,
+`@contentful/optimization-core/entry-source` manages only the entry source lifecycle.
+
+After resolution, render Web tracking metadata yourself or register the element manually with
+`optimization.tracking.enableElement(...)`. The entry-source controller does not emit `data-ctfl-*`
+attributes.
+
+### Core-only non-Web adapters
+
+Non-Web adapters own their rendering and interaction model. Convert runtime view, click, hover, tap,
+or screen behavior into the appropriate Core event calls, and pass the resolved entry plus
+`selectedOptimization` metadata to your runtime-specific tracking layer.
+
+### One-shot server paths
+
+Use `fetchOptimizedEntry(entryId, options?)` instead of the source controller when there is no
+mounted adapter lifecycle. It fetches the baseline entry through the configured `contentful.js`
+client, resolves immediately, and returns `{ baselineEntry, entry, selectedOptimization }`.
+
+## Validate the integration
+
+- Confirm the adapter renders loading and error states without tracking placeholder content as the
+ resolved entry.
+- Confirm `baselineEntry` takes precedence over `entryId`.
+- Confirm `entryId` changes or `entryQuery` changes do not render stale fetch results.
+- Confirm stateful adapters re-resolve when selected optimizations change, if live updates are part
+ of the adapter contract.
+- Confirm custom Web adapters render valid `data-ctfl-*` attributes or call
+ `tracking.enableElement(...)` after resolution.
+
+## Governance notes
+
+Use one concrete Contentful CDA locale for entries passed to Optimization resolvers. Do not use
+`contentful.js` `withAllLocales` or raw CDA `locale=*`; those responses produce locale-keyed field
+maps instead of the direct field values the resolver expects.
+
+The adapter owns rendering, tracking, consent UI, route or screen events, Experience API event
+timing, Contentful client creation, and teardown policy. Core owns shared optimization state,
+events, managed entry fetching when configured, and local entry resolution.
+
+## Related guides and concepts
+
+- [Choosing the right SDK](./choosing-the-right-sdk.md) - Package selection before building a custom
+ adapter.
+- [Entry optimization and variant resolution](../concepts/entry-personalization-and-variant-resolution.md) -
+ Resolver inputs, fallback behavior, and single-locale entry constraints.
+- [Interaction tracking in Web SDKs](../concepts/interaction-tracking-in-web-sdks.md) - Web
+ `data-ctfl-*` attributes and `enableElement(...)` mechanics.
+- [Optimization Core SDK README](../../packages/universal/core-sdk/README.md) - Core package surface
+ and entry-source subpath summary.
diff --git a/documentation/guides/choosing-the-right-sdk.md b/documentation/guides/choosing-the-right-sdk.md
index 13bb65114..ace866b3c 100644
--- a/documentation/guides/choosing-the-right-sdk.md
+++ b/documentation/guides/choosing-the-right-sdk.md
@@ -34,6 +34,17 @@ Angular, Vue, Svelte, Web Components, and custom browser framework apps use
`@contentful/optimization-node` unless the app is a Next.js App Router app covered by the Next.js
adapter.
+For JavaScript SDKs, we recommend the consumer-owned `contentful.js` path when the app already uses
+that client. Create the delivery client in your app, pass it to the Optimization SDK as
+`contentful: { client, defaultQuery?, cache? }`, then fetch optimized entries by entry ID through
+SDK helpers or framework entry props. Manual baseline-entry fetching plus `resolveOptimizedEntry()`
+remains supported when the app needs full delivery control or a non-`contentful.js` flow.
+
+For custom JavaScript runtimes or framework adapters where no official package fits, use Core plus
+the `@contentful/optimization-core/entry-source` subpath for managed `baselineEntry | entryId`
+lifecycle. Keep using the highest-level SDK when one fits; Core does not provide rendering,
+runtime-specific tracking, consent UI, or framework integration.
+
For mobile apps, choose `@contentful/optimization-react-native` when the mobile app is built with
JavaScript or TypeScript in React Native. Choose the native iOS or Android SDK only for
platform-native apps that can accept alpha native API and setup changes.
@@ -47,18 +58,19 @@ platform-native apps that can accept alpha native API and setup changes.
Use this table to choose the primary package and the next integration guide:
-| Reader need | Choose | Why | Next guide |
-| ---------------------------------------------------------------------------------------------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| Nest.js app, Node server, server function, or SSR layer outside the Next.js adapter | `@contentful/optimization-node` | It provides stateless, request-scoped profile evaluation, event emission, entry resolution, and caching guidance for Node runtimes. | [Integrating the Optimization Node SDK in a Node app](./integrating-the-node-sdk-in-a-node-app.md) |
-| Angular, Vue, Svelte, Web Components, non-React browser app, or custom browser framework app | `@contentful/optimization-web` | It owns browser consent state, anonymous ID persistence, automatic entry interaction tracking, browser event delivery, and Web Components. | [Integrating the Optimization Web SDK in a web app](./integrating-the-web-sdk-in-a-web-app.md) |
-| React browser app outside Next.js App Router integration | `@contentful/optimization-react-web` | It wraps the Web SDK with React providers, hooks, router page tracking, optimized entry rendering, interaction tracking, and live update semantics. | [Integrating the Optimization React Web SDK in a React app](./integrating-the-react-web-sdk-in-a-react-app.md) |
-| Next.js App Router app with server-personalized first paint that stays static after hydration | `@contentful/optimization-nextjs` | It uses Next.js server, client, and request-handler entrypoints for SSR personalization, request URL capture, tracking markup, browser-side tracking, and state handoff. | [Integrating the Optimization Next.js SDK in a Next.js app (SSR)](./integrating-the-optimization-sdk-in-a-nextjs-app-ssr.md) |
-| Next.js App Router app with server-personalized first paint and browser re-resolution after hydration | `@contentful/optimization-nextjs` | It keeps the personalized initial HTML and then lets the browser SDK own reactive entry resolution, live updates, route events, and preview-panel attachment. | [Integrating the Optimization Next.js SDK in a Next.js app (hybrid SSR + CSR takeover)](./integrating-the-optimization-sdk-in-a-nextjs-app-ssr-csr.md) |
-| React Native app | `@contentful/optimization-react-native` | It provides a stateful JavaScript mobile runtime with React providers, hooks, `OptimizedEntry`, screen tracking, optional offline-aware delivery, and preview-panel support. | [Integrating the Optimization React Native SDK in a React Native app](./integrating-the-react-native-sdk-in-a-react-native-app.md) |
-| Native iOS app built with SwiftUI that accepts alpha native API and setup changes | `ContentfulOptimization` Swift Package | It provides native Swift APIs, SwiftUI helpers, persistence, networking, lifecycle handling, screen tracking, entry rendering, and preview-panel UI. | [Integrating the Optimization iOS SDK in a SwiftUI app](./integrating-the-optimization-ios-sdk-in-a-swiftui-app.md) |
-| Native iOS app built with UIKit or direct client ownership that accepts alpha native API and setup changes | `ContentfulOptimization` Swift Package | It exposes the same native iOS runtime through direct client APIs and UIKit-compatible preview, screen tracking, and entry-rendering patterns. | [Integrating the Optimization iOS SDK in a UIKit app](./integrating-the-optimization-ios-sdk-in-a-uikit-app.md) |
-| Native Android app built with Jetpack Compose that accepts alpha native API and setup changes | `com.contentful.java:optimization-android` | The Android AAR includes the stateful Kotlin client, Compose UI helpers, screen tracking, entry optimization, preview controls, and offline event delivery. | [Integrating the Optimization Android SDK in a Jetpack Compose app](./integrating-the-optimization-android-sdk-in-a-compose-app.md) |
-| Native Android app built with Android Views or XML layouts that accepts alpha native API and setup changes | `com.contentful.java:optimization-android` | The same Android AAR includes Android Views helpers such as `OptimizationManager`, `OptimizedEntryView`, `ScreenTracker`, preview controls, and the stateful client. | [Integrating the Optimization Android SDK in an Android Views app](./integrating-the-optimization-android-sdk-in-a-views-app.md) |
+| Reader need | Choose | Why | Next guide |
+| ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| Nest.js app, Node server, server function, or SSR layer outside the Next.js adapter | `@contentful/optimization-node` | It provides stateless, request-scoped profile evaluation, event emission, managed Contentful entry fetching by ID, entry resolution, and caching guidance for Node runtimes. | [Integrating the Optimization Node SDK in a Node app](./integrating-the-node-sdk-in-a-node-app.md) |
+| Angular, Vue, Svelte, Web Components, non-React browser app, or custom browser framework app | `@contentful/optimization-web` | It owns browser consent state, anonymous ID persistence, managed Contentful entry fetching by ID, automatic entry interaction tracking, browser event delivery, and Web Components. | [Integrating the Optimization Web SDK in a web app](./integrating-the-web-sdk-in-a-web-app.md) |
+| React browser app outside Next.js App Router integration | `@contentful/optimization-react-web` | It wraps the Web SDK with React providers, hooks, router page tracking, optimized entry rendering by entry ID, interaction tracking, and live update semantics. | [Integrating the Optimization React Web SDK in a React app](./integrating-the-react-web-sdk-in-a-react-app.md) |
+| Next.js App Router app with server-personalized first paint that stays static after hydration | `@contentful/optimization-nextjs` | It uses Next.js server, client, and request-handler entrypoints for SSR personalization, managed Contentful entry fetching by ID, request URL capture, tracking markup, browser-side tracking, and state handoff. | [Integrating the Optimization Next.js SDK in a Next.js app (SSR)](./integrating-the-optimization-sdk-in-a-nextjs-app-ssr.md) |
+| Next.js App Router app with server-personalized first paint and browser re-resolution after hydration | `@contentful/optimization-nextjs` | It keeps the personalized initial HTML and then lets the browser SDK own reactive entry fetching by ID, entry resolution, live updates, route events, and preview-panel attachment. | [Integrating the Optimization Next.js SDK in a Next.js app (hybrid SSR + CSR takeover)](./integrating-the-optimization-sdk-in-a-nextjs-app-ssr-csr.md) |
+| Custom JavaScript runtime or framework adapter where no official SDK fits | `@contentful/optimization-core` plus `@contentful/optimization-core/entry-source` | Core provides shared state and resolution primitives. The entry-source subpath manages baseline-entry or entry-ID source lifecycle while the adapter owns rendering, tracking, and runtime policy. | [Building a custom JavaScript Optimization adapter](./building-a-custom-javascript-optimization-adapter.md) |
+| React Native app | `@contentful/optimization-react-native` | It provides a stateful JavaScript mobile runtime with React providers, hooks, `OptimizedEntry`, screen tracking, optional offline-aware delivery, and preview-panel support. | [Integrating the Optimization React Native SDK in a React Native app](./integrating-the-react-native-sdk-in-a-react-native-app.md) |
+| Native iOS app built with SwiftUI that accepts alpha native API and setup changes | `ContentfulOptimization` Swift Package | It provides native Swift APIs, SwiftUI helpers, persistence, networking, lifecycle handling, screen tracking, entry rendering, and preview-panel UI. | [Integrating the Optimization iOS SDK in a SwiftUI app](./integrating-the-optimization-ios-sdk-in-a-swiftui-app.md) |
+| Native iOS app built with UIKit or direct client ownership that accepts alpha native API and setup changes | `ContentfulOptimization` Swift Package | It exposes the same native iOS runtime through direct client APIs and UIKit-compatible preview, screen tracking, and entry-rendering patterns. | [Integrating the Optimization iOS SDK in a UIKit app](./integrating-the-optimization-ios-sdk-in-a-uikit-app.md) |
+| Native Android app built with Jetpack Compose that accepts alpha native API and setup changes | `com.contentful.java:optimization-android` | The Android AAR includes the stateful Kotlin client, Compose UI helpers, screen tracking, entry optimization, preview controls, and offline event delivery. | [Integrating the Optimization Android SDK in a Jetpack Compose app](./integrating-the-optimization-android-sdk-in-a-compose-app.md) |
+| Native Android app built with Android Views or XML layouts that accepts alpha native API and setup changes | `com.contentful.java:optimization-android` | The same Android AAR includes Android Views helpers such as `OptimizationManager`, `OptimizedEntryView`, `ScreenTracker`, preview controls, and the stateful client. | [Integrating the Optimization Android SDK in an Android Views app](./integrating-the-optimization-android-sdk-in-a-views-app.md) |
## Alternatives
@@ -67,7 +79,9 @@ Use this table to choose the primary package and the next integration guide:
existing Contentful client and Web SDK instance; it is not a standalone SDK.
- **Core SDK** - Use `@contentful/optimization-core` when building or maintaining an SDK layer that
needs the shared state machine, event builders, queues, resolvers, interceptors, or preview
- support. Application integrations start with a platform SDK.
+ support. Use `@contentful/optimization-core/entry-source` only when building an adapter that must
+ manage `baselineEntry | entryId` source lifecycle before resolution. Application integrations
+ start with a platform SDK.
- **API client** - Use `@contentful/optimization-api-client` when building SDK layers, tooling,
tests, or first-party integrations that need direct Experience API or Insights API transport
without SDK state, consent handling, event builders, entry resolution, tracking, or platform
@@ -90,6 +104,7 @@ After choosing the package, follow the matching guide:
| React browser apps | [Integrating the Optimization React Web SDK in a React app](./integrating-the-react-web-sdk-in-a-react-app.md) |
| Next.js App Router SSR | [Integrating the Optimization Next.js SDK in a Next.js app (SSR)](./integrating-the-optimization-sdk-in-a-nextjs-app-ssr.md) |
| Next.js App Router hybrid SSR and CSR takeover | [Integrating the Optimization Next.js SDK in a Next.js app (hybrid SSR + CSR takeover)](./integrating-the-optimization-sdk-in-a-nextjs-app-ssr-csr.md) |
+| Custom JavaScript adapter | [Building a custom JavaScript Optimization adapter](./building-a-custom-javascript-optimization-adapter.md) |
| React Native apps | [Integrating the Optimization React Native SDK in a React Native app](./integrating-the-react-native-sdk-in-a-react-native-app.md) |
| iOS SwiftUI apps | [Integrating the Optimization iOS SDK in a SwiftUI app](./integrating-the-optimization-ios-sdk-in-a-swiftui-app.md) |
| iOS UIKit apps | [Integrating the Optimization iOS SDK in a UIKit app](./integrating-the-optimization-ios-sdk-in-a-uikit-app.md) |
diff --git a/documentation/guides/forwarding-optimization-sdk-context-to-analytics-and-tag-management-tools.md b/documentation/guides/forwarding-optimization-sdk-context-to-analytics-and-tag-management-tools.md
index 8b1a95936..4c7e5f4e1 100644
--- a/documentation/guides/forwarding-optimization-sdk-context-to-analytics-and-tag-management-tools.md
+++ b/documentation/guides/forwarding-optimization-sdk-context-to-analytics-and-tag-management-tools.md
@@ -315,8 +315,10 @@ called a request-bound SDK method and owns the analytics event for that request.
Use the `data` from the same accepted SDK call that rendered the response or handled the server
event. For Next.js helpers, `getNextjsServerOptimizationData()` returns the same `OptimizationData`
-shape in its `data` field. Browser state streams cannot explain a server-rendered first paint unless
-you intentionally hydrate the browser with the same Optimization data.
+shape in its `data` field. When the SDK is configured with `contentful: { client }`, prefer the
+request-bound managed entry helper so the entry decision and analytics context share the same
+request data. Browser state streams cannot explain a server-rendered first paint unless you
+intentionally hydrate the browser with the same Optimization data.
**Adapt this to your use case:**
@@ -327,11 +329,12 @@ const pageResult = await requestOptimization.page({
const optimizationData = pageResult.accepted ? pageResult.data : undefined
-// Resolve the entry and analytics context from the same request-local Optimization data.
-const { entry: resolvedHeroEntry, selectedOptimization } = optimization.resolveOptimizedEntry(
- baselineHeroEntry,
- optimizationData?.selectedOptimizations,
-)
+// Fetch and resolve the entry with the same request-local Optimization data.
+const {
+ baselineEntry,
+ entry: resolvedHeroEntry,
+ selectedOptimization,
+} = await requestOptimization.fetchOptimizedEntry('hero-entry-id')
if (appPolicyAllowsThirdPartyAnalytics()) {
// The server event owner decides which Contentful fields belong on this business event.
@@ -345,12 +348,16 @@ if (appPolicyAllowsThirdPartyAnalytics()) {
contentful_experience_id: selectedOptimization?.experienceId,
contentful_variant_index: selectedOptimization?.variantIndex,
contentful_variant_entry_id: selectedOptimization ? resolvedHeroEntry.sys.id : undefined,
- contentful_baseline_entry_id: baselineHeroEntry.sys.id,
+ contentful_baseline_entry_id: baselineEntry.sys.id,
}),
)
}
```
+For manual Contentful fetching, keep passing an app-fetched `baselineEntry` and
+`optimizationData?.selectedOptimizations` to `resolveOptimizedEntry()`, then forward the same fields
+from the returned result.
+
In stateless runtimes, Insights-only calls such as non-sticky `trackView()`, `trackClick()`,
`trackHover()`, and `trackFlagView()` need a request-bound profile. Sticky `trackView()` returns
Optimization data from the Experience path before sending the paired Insights event.
diff --git a/documentation/guides/integrating-the-node-sdk-in-a-node-app.md b/documentation/guides/integrating-the-node-sdk-in-a-node-app.md
index d0b98e759..776b56879 100644
--- a/documentation/guides/integrating-the-node-sdk-in-a-node-app.md
+++ b/documentation/guides/integrating-the-node-sdk-in-a-node-app.md
@@ -137,31 +137,33 @@ The JSON response contains a `profileId` when `page()` is accepted.
Use this setup inventory before you move beyond the quick start:
-| Setup item | Category | Required for quick start | Where to configure |
-| --------------------------------------------------------------- | ------------------------------ | ------------------------ | ---------------------------------------------------------------------------------------- |
-| `@contentful/optimization-node` package | Required for first integration | Yes | Node app package dependencies |
-| Express package for the quick-start route | Required for first integration | Yes | Quick-start app dependencies, or your equivalent Node request framework |
-| Optimization client ID | Required for first integration | Yes | `ContentfulOptimization({ clientId })` from environment configuration |
-| Optimization environment and API endpoints | Required for first integration | Conditional | `environment` and `api` SDK options when not using defaults or local mocks |
-| Application Contentful delivery client | Required for first integration | Conditional | App-owned `contentful.js`, REST, or GraphQL client used before entry resolution |
-| Single-locale Contentful entry payloads with optimization links | Required for first integration | Conditional | CDA request options such as `locale: appLocale` and `include` depth |
-| Request route or handler integration | Required for first integration | Yes | Express routes, server functions, or custom Node request handlers |
-| Request event context | Required for first integration | Yes | `forRequest({ eventContext })` per incoming request |
-| Application locale decision | Required for first integration | Yes | Router, i18n layer, request policy, CDA requests, and `forRequest({ locale })` |
-| Consent policy | Common but policy-dependent | Yes | Application policy, consent cookie, CMP callback, session, or preference store |
-| Profile ID persistence | Common but policy-dependent | Conditional | Application session or first-party cookie such as `ANONYMOUS_ID_COOKIE` |
-| Known-user identity source | Common but policy-dependent | No | Authentication middleware, session, JWT, or account service used before `identify()` |
-| Rich Text merge-tag renderer | Optional | No | Application Rich Text rendering pipeline |
-| Custom Flag reads | Optional | No | Server render logic that consumes `getFlag()` |
-| Server-side interaction or business event tracking | Optional | No | App-owned event collector, route action, or rendered-entry exposure path |
-| Third-party analytics forwarding | Optional | No | Server-side analytics, customer-data, or tag-management integration |
-| `@contentful/optimization-web` package and continuity | Optional | No | Browser package dependencies, shared anonymous-ID cookie, and browser SDK initialization |
-| Strict pre-consent allowlist and request options | Advanced or production-only | No | SDK `allowedEventTypes`, `experienceOptions`, `insightsOptions`, and `onEventBlocked` |
-| Personalized response caching policy | Advanced or production-only | No | Application cache keys, CDN rules, and render cache boundaries |
+| Setup item | Category | Required for quick start | Where to configure |
+| --------------------------------------------------------------- | ------------------------------ | ------------------------ | ------------------------------------------------------------------------------------------------ |
+| `@contentful/optimization-node` package | Required for first integration | Yes | Node app package dependencies |
+| Express package for the quick-start route | Required for first integration | Yes | Quick-start app dependencies, or your equivalent Node request framework |
+| Optimization client ID | Required for first integration | Yes | `ContentfulOptimization({ clientId })` from environment configuration |
+| Optimization environment and API endpoints | Required for first integration | Conditional | `environment` and `api` SDK options when not using defaults or local mocks |
+| Application Contentful delivery client | Required for first integration | Conditional | App-owned `contentful.js`, REST, or GraphQL client; SDK-managed fetching can use `contentful.js` |
+| Single-locale Contentful entry payloads with optimization links | Required for first integration | Conditional | CDA request options such as `locale: appLocale` and `include` depth |
+| Request route or handler integration | Required for first integration | Yes | Express routes, server functions, or custom Node request handlers |
+| Request event context | Required for first integration | Yes | `forRequest({ eventContext })` per incoming request |
+| Application locale decision | Required for first integration | Yes | Router, i18n layer, request policy, CDA requests, and `forRequest({ locale })` |
+| Consent policy | Common but policy-dependent | Yes | Application policy, consent cookie, CMP callback, session, or preference store |
+| Profile ID persistence | Common but policy-dependent | Conditional | Application session or first-party cookie such as `ANONYMOUS_ID_COOKIE` |
+| Known-user identity source | Common but policy-dependent | No | Authentication middleware, session, JWT, or account service used before `identify()` |
+| Rich Text merge-tag renderer | Optional | No | Application Rich Text rendering pipeline |
+| Custom Flag reads | Optional | No | Server render logic that consumes `getFlag()` |
+| Server-side interaction or business event tracking | Optional | No | App-owned event collector, route action, or rendered-entry exposure path |
+| Third-party analytics forwarding | Optional | No | Server-side analytics, customer-data, or tag-management integration |
+| `@contentful/optimization-web` package and continuity | Optional | No | Browser package dependencies, shared anonymous-ID cookie, and browser SDK initialization |
+| Strict pre-consent allowlist and request options | Advanced or production-only | No | SDK `allowedEventTypes`, `experienceOptions`, `insightsOptions`, and `onEventBlocked` |
+| Personalized response caching policy | Advanced or production-only | No | Application cache keys, CDN rules, and render cache boundaries |
The Node SDK is stateless. It does not manage cookies, sessions, consent state, long-lived profile
-state, Contentful fetching, or HTML rendering. Your application provides those inputs per request,
-and the SDK evaluates or emits events, resolves entries, and returns request-local data.
+state, Contentful credentials, or HTML rendering. Your application owns the Contentful delivery
+client and its credentials; when configured with that client, the SDK can call `client.getEntry()`
+for managed entry fetching. Your application provides request inputs, and the SDK evaluates or emits
+events, fetches configured entries, resolves variants, and returns request-local data.
## Core integration
@@ -172,7 +174,7 @@ and the SDK evaluates or emits events, resolves entries, and returns request-loc
Create the SDK once for the Node process or module, then reuse that singleton across requests. Bind
request-specific inputs later with `forRequest()`.
-1. Install `@contentful/optimization-node`.
+1. Install `@contentful/optimization-node` and `contentful` when the SDK will fetch entries by ID.
2. Read the Optimization client ID and environment from your runtime configuration.
3. Configure default locale and API endpoint overrides only when your app needs them.
4. Export the singleton so route handlers can create request-bound SDK clients.
@@ -180,13 +182,14 @@ request-specific inputs later with `forRequest()`.
**Copy this:**
```sh
-pnpm add @contentful/optimization-node
+pnpm add @contentful/optimization-node contentful
```
**Copy this:**
```ts
import ContentfulOptimization from '@contentful/optimization-node'
+import * as contentful from 'contentful'
function required(name: string): string {
const value = process.env[name]
@@ -198,9 +201,20 @@ function required(name: string): string {
return value
}
+const contentfulClient = contentful.createClient({
+ accessToken: required('CONTENTFUL_DELIVERY_TOKEN'),
+ environment: required('CONTENTFUL_ENVIRONMENT'),
+ space: required('CONTENTFUL_SPACE_ID'),
+})
+
// Create this once per process; use forRequest() inside route handlers.
export const optimization = new ContentfulOptimization({
clientId: required('CONTENTFUL_OPTIMIZATION_CLIENT_ID'),
+ contentful: {
+ client: contentfulClient,
+ // Include linked optimization entries and variants for SDK-managed entry fetches.
+ defaultQuery: { include: 10 },
+ },
environment: process.env.CONTENTFUL_OPTIMIZATION_ENVIRONMENT ?? 'main',
app: {
name: 'my-express-app',
@@ -475,24 +489,24 @@ For the lower-level mechanics, see
**Integration category:** Required for first integration
-The Node SDK does not replace your Contentful delivery client. Fetch the baseline Contentful entry
-with the application locale and enough include depth, then pass that entry and request-local
-`selectedOptimizations` to `resolveOptimizedEntry()`.
+Your app owns the Contentful delivery client, credentials, and delivery policy. The preferred
+Contentful path passes an app-owned `contentful.js` client to the SDK, then calls the request-bound
+`requestOptimization.fetchOptimizedEntry(entryId)` helper after `page()` or `identify()`.
After verifying the first `profileId` response, this section is where you add Contentful rendering:
-pass the `selectedOptimizations` returned by `page()` to `resolveOptimizedEntry()` before rendering
-the response.
+call `requestOptimization.fetchOptimizedEntry(entryId)` before rendering the response. The helper
+fetches the baseline entry, resolves the selected variant, and uses the latest accepted Experience
+response selections when `selectedOptimizations` is omitted.
-1. Fetch a single-locale Contentful entry from the application layer.
-2. Include linked optimization entries and variant entries in the Contentful response.
-3. Call `resolveOptimizedEntry()` with the request's `selectedOptimizations`.
+1. Configure the SDK with `contentful: { client, defaultQuery?, cache? }`.
+2. Call `page()` or `identify()` before resolving entries for the response.
+3. Call `requestOptimization.fetchOptimizedEntry(entryId)` inside the request handler.
4. Render the returned `entry`. If resolution cannot find a matching optimization or variant, the
resolver returns the baseline entry.
**Adapt this to your use case:**
```ts
-import type { Entry } from 'contentful'
import * as contentful from 'contentful'
const contentfulClient = contentful.createClient({
@@ -501,16 +515,16 @@ const contentfulClient = contentful.createClient({
space: required('CONTENTFUL_SPACE_ID'),
})
-type ArticleEntry = Entry
-
-async function getArticle(entryId: string, locale: string): Promise {
- return await contentfulClient.getEntry(entryId, {
+const optimization = new ContentfulOptimization({
+ clientId: required('CONTENTFUL_OPTIMIZATION_CLIENT_ID'),
+ contentful: {
+ client: contentfulClient,
// Include linked optimization entries and variants before SDK resolution.
- include: 10,
- // Fetch one CDA locale; all-locale payloads cannot be resolved by the SDK.
- locale,
- })
-}
+ defaultQuery: { include: 10 },
+ },
+ environment: process.env.CONTENTFUL_OPTIMIZATION_ENVIRONMENT ?? 'main',
+ locale: 'en-US',
+})
app.get('/article/:entryId', async (req, res) => {
const appLocale = getAppLocale(req)
@@ -523,13 +537,11 @@ app.get('/article/:entryId', async (req, res) => {
// Evaluate the request before resolving entry variants for this response.
const pageResult = await requestOptimization.page()
const pageResponse = pageResult.accepted ? pageResult.data : undefined
- const article = await getArticle(req.params.entryId, appLocale)
-
- // The resolver returns article when no matching selected optimization exists.
- const { entry: optimizedArticle, selectedOptimization } = optimization.resolveOptimizedEntry(
- article,
- pageResponse?.selectedOptimizations,
- )
+ const {
+ baselineEntry: article,
+ entry: optimizedArticle,
+ selectedOptimization,
+ } = await requestOptimization.fetchOptimizedEntry(req.params.entryId)
if (requestOptimization.canPersistProfile) {
persistProfile(res, pageResponse?.profile.id)
@@ -543,8 +555,29 @@ app.get('/article/:entryId', async (req, res) => {
})
```
-Do not pass all-locale CDA responses from `contentful.js` `withAllLocales` or raw CDA `locale=*`
-into `resolveOptimizedEntry()`. The resolver expects direct single-locale field values such as
+Use `requestOptimization.fetchOptimizedEntry()` in request handlers. If you call
+`optimization.fetchOptimizedEntry()` on the singleton for personalized content, pass
+`selectedOptimizations` explicitly.
+
+Manual baseline-entry fetching plus `resolveOptimizedEntry()` remains supported when the app needs
+custom delivery behavior, GraphQL, REST without `contentful.js`, or an already-fetched baseline
+entry:
+
+**Adapt this to your use case:**
+
+```ts
+const baselineEntry = await contentfulClient.getEntry(req.params.entryId, {
+ include: 10,
+ locale: appLocale,
+})
+const { entry: optimizedArticle } = optimization.resolveOptimizedEntry(
+ baselineEntry,
+ pageResponse?.selectedOptimizations,
+)
+```
+
+Do not configure SDK-managed fetches or manual fetches with `contentful.js` `withAllLocales` or raw
+CDA `locale=*` responses. The resolver expects direct single-locale field values such as
`fields.nt_experiences` and `fields.nt_variants`. For the entry contract, see
[Entry personalization and variant resolution](../concepts/entry-personalization-and-variant-resolution.md#single-locale-cda-entry-contract).
@@ -582,7 +615,9 @@ const html = documentToHtmlString(richTextField, {
If MergeTags reference localized profile fields such as `location.city` or `location.country`, pass
the same application locale to Contentful fetches and `forRequest({ locale })` so profile values and
-entry language line up.
+entry language line up. For SDK-managed entry fetching on a request-bound client,
+`forRequest({ locale })` supplies the managed Contentful query locale when neither
+`contentful.defaultQuery` nor the per-call query sets `locale`.
### Read Custom Flags
@@ -813,20 +848,22 @@ cache varies on every personalization input.
environment, host, and delivery mode.
2. Treat cached Contentful entries as immutable, or clone them before request-specific transforms
such as merge-tag rendering.
-3. Resolve variants from the current request's `selectedOptimizations`.
+3. Resolve variants from the current request's `selectedOptimizations`, or use
+ `requestOptimization.fetchOptimizedEntry()` so the request-bound helper supplies them.
4. Render MergeTags against the current request's `profile`.
5. Do not memoize `page()`, `identify()`, `screen()`, `track()`, or `trackView()` results as if they
were pure reads.
Use this cache-safety table when planning production caching:
-| Artifact | Shared-cache safe? | Notes |
-| -------------------------------------------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------- |
-| Raw `contentful.js` entry or query response | Yes | Key by entry or query, locale, include depth, environment, host, and delivery mode |
-| `resolveOptimizedEntry(entry, selectedOptimizations)` result | Conditional | Safe only if keyed by the baseline entry version plus a `selectedOptimizations` fingerprint |
-| Merge-tag-rendered rich text | No | Depends on the current request `profile` |
-| SSR HTML with personalized content | Usually no | Safe only when the cache varies on all personalization inputs |
-| `page()`, `identify()`, `screen()`, `track()`, and `trackView()` responses | No | These methods perform side effects and must not be memoized |
+| Artifact | Shared-cache safe? | Notes |
+| -------------------------------------------------------------------------- | ------------------ | ----------------------------------------------------------------------------------------------------------- |
+| Raw `contentful.js` entry or query response | Yes | Key by entry or query, locale, include depth, environment, host, and delivery mode |
+| SDK-managed entry cache | Yes | Caches baseline `getEntry()` results only; configure with `contentful.cache` or disable with `cache: false` |
+| `resolveOptimizedEntry(entry, selectedOptimizations)` result | Conditional | Safe only if keyed by the baseline entry version plus a `selectedOptimizations` fingerprint |
+| Merge-tag-rendered rich text | No | Depends on the current request `profile` |
+| SSR HTML with personalized content | Usually no | Safe only when the cache varies on all personalization inputs |
+| `page()`, `identify()`, `screen()`, `track()`, and `trackView()` responses | No | These methods perform side effects and must not be memoized |
## Production checks
diff --git a/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr-csr.md b/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr-csr.md
index 63cc7da45..46d7dc71c 100644
--- a/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr-csr.md
+++ b/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr-csr.md
@@ -6,8 +6,8 @@ handoff when consent, identity, profile, preview, or route state changes.
This pattern uses `@contentful/optimization-nextjs`. The server entry composes the stateless Node
SDK, the client entry composes the React Web SDK, and the request handler forwards sanitized Next.js
-proxy or middleware request context headers. Your application still owns Contentful fetching,
-consent policy, identity policy, routing, caching, and component rendering.
+proxy or middleware request context headers. Your application still owns the Contentful client and
+credentials, consent policy, identity policy, routing, caching, and component rendering.
If browser-side actions do not need to change visible content until the next request, use the
[Next.js SSR guide](./integrating-the-optimization-sdk-in-a-nextjs-app-ssr.md) instead.
@@ -20,12 +20,13 @@ before browser takeover. If consent depends on a consent management platform (CM
account preference, or user choice, keep the same structure and apply the policy-dependent consent
section before release.
-1. Install the Next.js adapter package.
+1. Install the Next.js adapter package. Add `contentful` only if your app does not already have a
+ Contentful Delivery API client.
**Copy this:**
```sh
- pnpm add @contentful/optimization-nextjs
+ pnpm add @contentful/optimization-nextjs contentful
```
2. Create one server SDK singleton and a cached request helper that returns server optimization
@@ -40,14 +41,29 @@ section before release.
createNextjsOptimization,
getNextjsServerOptimizationData,
} from '@contentful/optimization-nextjs/server'
+ import { createClient } from 'contentful'
import { cookies, headers } from 'next/headers'
import { cache } from 'react'
export const APP_LOCALE = 'en-US'
+ const contentfulClient = createClient({
+ accessToken: process.env.CONTENTFUL_DELIVERY_TOKEN ?? '',
+ environment: process.env.CONTENTFUL_ENVIRONMENT ?? 'main',
+ space: process.env.CONTENTFUL_SPACE_ID ?? '',
+ })
+
// Keep one server SDK instance; bind request state through adapter helpers.
export const optimization = createNextjsOptimization({
clientId: process.env.CONTENTFUL_OPTIMIZATION_CLIENT_ID ?? '',
+ contentful: {
+ client: contentfulClient,
+ defaultQuery: {
+ // Managed fetching expects one CDA locale and resolved optimization links.
+ include: 10,
+ locale: APP_LOCALE,
+ },
+ },
environment: process.env.CONTENTFUL_OPTIMIZATION_ENVIRONMENT ?? 'main',
locale: APP_LOCALE,
api: {
@@ -61,22 +77,39 @@ section before release.
logLevel: 'error',
})
- export const getOptimizationData = cache(async () => {
+ export const getOptimizationRequest = cache(async () => {
const [cookieStore, headerStore] = await Promise.all([cookies(), headers()])
// Accepted startup allows the server page call and returns profile data for browser handoff.
- const { data } = await getNextjsServerOptimizationData(optimization, {
+ const { data, requestOptimization } = await getNextjsServerOptimizationData(optimization, {
consent: { events: true, persistence: true },
cookies: cookieStore,
headers: headerStore,
locale: APP_LOCALE,
})
- return data
+ return { optimizationData: data, requestOptimization }
+ })
+ ```
+
+3. Create a browser-safe Contentful delivery client for the takeover boundary.
+
+ **Copy this:**
+
+ ```ts
+ // lib/contentful-browser-client.ts
+ import { createClient } from 'contentful'
+
+ export const contentfulClient = createClient({
+ accessToken: process.env.NEXT_PUBLIC_CONTENTFUL_DELIVERY_TOKEN ?? '',
+ environment: process.env.NEXT_PUBLIC_CONTENTFUL_ENVIRONMENT ?? 'main',
+ space: process.env.NEXT_PUBLIC_CONTENTFUL_SPACE_ID ?? '',
})
```
-3. Create a Client Component boundary that renders server content outside the SDK context, then
+ Use only browser-safe Delivery API credentials in `NEXT_PUBLIC_` variables.
+
+4. Create a Client Component boundary that renders server content outside the SDK context, then
reveals the browser takeover island after browser SDK state is ready.
**Adapt this to your use case:**
@@ -91,6 +124,7 @@ section before release.
type NextAppAutoPageTrackerProps,
type OptimizationRootProps,
} from '@contentful/optimization-nextjs/client'
+ import { contentfulClient } from '@/lib/contentful-browser-client'
import { Suspense, useState, type ReactNode } from 'react'
export function HybridTakeoverBoundary({
@@ -117,6 +151,10 @@ section before release.
clientId={process.env.NEXT_PUBLIC_CONTENTFUL_OPTIMIZATION_CLIENT_ID ?? ''}
environment={process.env.NEXT_PUBLIC_CONTENTFUL_OPTIMIZATION_ENVIRONMENT ?? 'main'}
locale={locale}
+ contentful={{
+ client: contentfulClient,
+ defaultQuery: { include: 10, locale },
+ }}
defaults={defaults}
serverOptimizationState={serverOptimizationState}
logLevel="error"
@@ -136,8 +174,8 @@ section before release.
}
```
-4. Fetch single-locale Contentful entries in a Server Component, resolve the initial HTML with the
- server data, and pass both baseline and server-resolved data to the takeover island.
+5. Fetch and resolve single-locale Contentful entries in a Server Component, render the initial HTML
+ with the server data, and pass entry IDs plus server-resolved data to the takeover island.
**Adapt this to your use case:**
@@ -145,8 +183,7 @@ section before release.
// app/page.tsx
import { HybridEntryList } from '@/components/HybridEntryList'
import { HybridTakeoverBoundary } from '@/components/HybridTakeoverBoundary'
- import { fetchEntriesFromContentful } from '@/lib/contentful-client'
- import { APP_LOCALE, getOptimizationData, optimization } from '@/lib/optimization-server'
+ import { APP_LOCALE, getOptimizationRequest } from '@/lib/optimization-server'
import type { Entry } from 'contentful'
function EntryCard({ entry }: { entry: Entry }) {
@@ -164,16 +201,14 @@ section before release.
}
export default async function Home() {
- const [baselineEntries, optimizationData] = await Promise.all([
- fetchEntriesFromContentful(['home-hero', 'home-offer']),
- getOptimizationData(),
- ])
-
- // Resolve locally against request-selected optimizations, with baseline fallback.
- const serverResolvedData = baselineEntries.map((baselineEntry) =>
- optimization.resolveOptimizedEntry(baselineEntry, optimizationData?.selectedOptimizations),
+ const entryIds = ['home-hero', 'home-offer']
+ const { optimizationData, requestOptimization } = await getOptimizationRequest()
+
+ // Fetch baseline entries and resolve them against request-selected optimizations.
+ const serverResults = await Promise.all(
+ entryIds.map((entryId) => requestOptimization.fetchOptimizedEntry(entryId)),
)
- const serverEntries = serverResolvedData.map(({ entry }) => entry)
+ const serverEntries = serverResults.map(({ entry }) => entry)
const defaults = { consent: true }
return (
@@ -184,18 +219,16 @@ section before release.
serverContent={}
serverOptimizationState={optimizationData}
>
-
+
)
}
```
-5. Render the browser takeover entries through `OptimizedEntry`. Use the server-resolved entry as
- the loading fallback so takeover content matches the initial HTML while the browser SDK becomes
- ready.
+6. Render the browser takeover entries through `OptimizedEntry entryId`. Use the server-resolved
+ entry as the loading and error fallback so takeover content matches the initial HTML while the
+ browser SDK becomes ready. Replace `reportContentfulEntryError` with your app-owned logging or
+ monitoring function.
**Adapt this to your use case:**
@@ -211,23 +244,26 @@ section before release.
}
export function HybridEntryList({
- baselineEntries,
- serverResolvedData,
+ entryIds,
+ serverResults,
}: {
- baselineEntries: Entry[]
- serverResolvedData: { entry: Entry }[]
+ entryIds: string[]
+ serverResults: { entry: Entry }[]
}) {
return (
<>
- {baselineEntries.map((baselineEntry, index) => {
- const serverEntry = serverResolvedData[index]?.entry ?? baselineEntry
+ {entryIds.map((entryId, index) => {
+ const serverEntry = serverResults[index]?.entry
+ if (!serverEntry) return null
// loadingFallback matches the server-selected content during browser takeover.
return (
}
loadingFallback={}
+ onEntryError={(error) => reportContentfulEntryError(error)}
>
{(resolvedEntry) => }
@@ -238,7 +274,7 @@ section before release.
}
```
-6. Verify the first run. The route source must contain the server-selected content or baseline
+7. Verify the first run. The route source must contain the server-selected content or baseline
fallback, that content must remain visible after browser takeover, and the browser must not emit
a duplicate initial page event when `initialPageEvent="skip"` is used with server optimization
state.
@@ -284,7 +320,7 @@ Use this table as the setup inventory for the full hybrid integration:
| `@contentful/optimization-nextjs` package | Required for first integration | Yes | Application package manager |
| Optimization client ID and environment | Required for first integration | Yes | Server SDK config and client takeover `OptimizationRoot` props |
| Server-only and browser-exposed environment variables | Required for first integration | Yes | Runtime environment, including `NEXT_PUBLIC_` variables for browser config |
-| Contentful CDA credentials and app-owned fetcher | Required for first integration | Yes | Application Contentful client |
+| Contentful CDA credentials and app-owned `contentful.js` client | Required for first integration | Yes | Server SDK `contentful` config and browser takeover `OptimizationRoot` |
| Single-locale CDA entries with resolved optimization links | Required for first integration | Yes | CDA calls with one `locale` and enough `include` depth, commonly `include: 10` |
| Server request helper for Optimization data | Required for first integration | Yes | Server-only page-event owner using `getNextjsServerOptimizationData()` and `cache()` |
| Next.js proxy or middleware hook | Common but policy-dependent | No | `proxy.ts` or `middleware.ts` for server helpers that need request context |
@@ -303,8 +339,9 @@ Use this table as the setup inventory for the full hybrid integration:
| Personalized response caching and duplicate-event policy | Advanced or production-only | No | Next.js route config, CDN rules, server helper structure, and tracker settings |
Use one application Contentful locale for entries that feed SDK resolution. The SDK Experience and
-event locale often uses the same string, but the SDK does not fetch Contentful content or change CDA
-requests for you.
+event locale often uses the same string. Preferred hybrid integrations create the `contentful.js`
+client in the app and pass it to the SDK through `contentful` config. Manual `baselineEntry`
+rendering remains supported when entries are fetched outside the SDK.
## Core integration
@@ -336,11 +373,12 @@ importing lower-level Node, Web, or React Web packages directly.
**Copy this:**
```sh
-pnpm add @contentful/optimization-nextjs
+pnpm add @contentful/optimization-nextjs contentful
```
The adapter package lists Next.js, React, and React DOM as application-owned peer dependencies. It
-uses the runtime that is already installed by the app.
+uses the runtime that is already installed by the app. The `contentful` package is the app-owned CDA
+client used by managed entry fetching examples.
### Server SDK and request helper
@@ -351,12 +389,14 @@ consent, locale, and profile through request helpers. Use the proxy or middlewar
Server Components can derive page context from forwarded request context headers.
1. Read the Optimization client ID and environment from server-only runtime configuration.
-2. Pass the application Experience/event locale to the SDK singleton.
-3. Configure API endpoint overrides only when your app uses mocks, a proxy, or non-default hosts.
-4. Wrap `getNextjsServerOptimizationData()` in React `cache()` when more than one Server Component
+2. Configure the app-owned `contentful.js` client and the single-locale default CDA query on the SDK
+ singleton.
+3. Pass the application Experience/event locale to the SDK singleton.
+4. Configure API endpoint overrides only when your app uses mocks, a proxy, or non-default hosts.
+5. Wrap `getNextjsServerOptimizationData()` in React `cache()` when more than one Server Component
in the same render pass needs the same request-local Optimization data.
-5. Add the request-context proxy or middleware helper for routes that call the server helper.
-6. Return `undefined` instead of calling the SDK when application policy does not permit server
+6. Add the request-context proxy or middleware helper for routes that call the server helper.
+7. Return `undefined` instead of calling the SDK when application policy does not permit server
personalization for the request.
**Adapt this to your use case:**
@@ -367,6 +407,7 @@ import {
createNextjsOptimization,
getNextjsServerOptimizationData,
} from '@contentful/optimization-nextjs/server'
+import { createClient } from 'contentful'
import { cookies, headers } from 'next/headers'
import { cache } from 'react'
@@ -374,9 +415,19 @@ export const APP_LOCALE = 'en-US'
const APP_PERSONALIZATION_CONSENT_COOKIE = 'app-personalization-consent'
+const contentfulClient = createClient({
+ accessToken: process.env.CONTENTFUL_DELIVERY_TOKEN ?? '',
+ environment: process.env.CONTENTFUL_ENVIRONMENT ?? 'main',
+ space: process.env.CONTENTFUL_SPACE_ID ?? '',
+})
+
// Keep one server SDK instance; bind request state through adapter helpers.
export const optimization = createNextjsOptimization({
clientId: process.env.CONTENTFUL_OPTIMIZATION_CLIENT_ID ?? '',
+ contentful: {
+ client: contentfulClient,
+ defaultQuery: { include: 10, locale: APP_LOCALE },
+ },
environment: process.env.CONTENTFUL_OPTIMIZATION_ENVIRONMENT ?? 'main',
locale: APP_LOCALE,
api: {
@@ -390,29 +441,30 @@ export const optimization = createNextjsOptimization({
logLevel: 'error',
})
-export const getOptimizationData = cache(async () => {
+export const getOptimizationRequest = cache(async () => {
const [cookieStore, headerStore] = await Promise.all([cookies(), headers()])
const appConsent = cookieStore.get(APP_PERSONALIZATION_CONSENT_COOKIE)?.value
if (appConsent === 'denied') return undefined
// Accepted startup allows the server page call and returns profile data for browser handoff.
- const { data } = await getNextjsServerOptimizationData(optimization, {
+ const { data, requestOptimization } = await getNextjsServerOptimizationData(optimization, {
consent: { events: true, persistence: true },
cookies: cookieStore,
headers: headerStore,
locale: APP_LOCALE,
})
- return data
+ return { optimizationData: data, requestOptimization }
})
```
`getNextjsServerOptimizationData()` calls `page()` through the request-bound Node SDK and returns
-the profile, selected optimizations, and changes for that request. Treat that call as the initial
-server page event for the route. The request-scoped `locale` is sent to the Experience API and used
-as the default event context locale. In Server Components, pass `headers()` so the SDK can derive
-page context from the request URL captured by the Next.js proxy or middleware helper.
+the profile, selected optimizations, changes, and `requestOptimization` helper for that request.
+Treat that call as the initial server page event for the route. The request-scoped `locale` is sent
+to the Experience API and used as the default event context locale. In Server Components, pass
+`headers()` so the SDK can derive page context from the request URL captured by the Next.js proxy or
+middleware helper.
### Proxy request context
@@ -449,12 +501,15 @@ handoff or an explicit server persistence flow. For deeper mechanics, see
**Integration category:** Required for first integration
-The SDK does not fetch Contentful entries. Fetch baseline entries in the application layer with one
-Contentful locale and resolved optimization links before passing them to the server resolver or
-client entry primitives.
+Preferred hybrid integrations configure the app-owned `contentful.js` client on the server SDK and
+browser `OptimizationRoot`. Server Components use
+`requestOptimization.fetchOptimizedEntry(entryId)`. Browser takeover can use
+`OptimizedEntry entryId` or `useOptimizedEntry({ entryId })`. Manual `baselineEntry` remains
+supported when entries are fetched outside the SDK.
1. Choose the application Contentful locale in routing, i18n, request policy, or app configuration.
-2. Pass that locale to CDA requests.
+2. Pass that locale through server SDK `contentful.defaultQuery`, browser `OptimizationRoot`
+ `contentful.defaultQuery`, per-entry `entryQuery`, or manual CDA requests.
3. Include linked optimization entries and variant entries. The common Contentful CDA setting is
`include: 10`.
4. Do not pass all-locale CDA responses from `contentful.js` `withAllLocales` or raw CDA `locale=*`
@@ -493,6 +548,9 @@ export async function fetchEntriesFromContentful(entryIds: readonly string[]): P
}
```
+Use this manual helper only for flows that keep app-owned baseline fetching. Managed server and
+browser entry rendering can use the configured SDK client instead.
+
Single-locale entries expose optimization fields as direct field values, such as
`fields.nt_experiences` and `fields.nt_variants`. All-locale responses use locale-keyed field maps
and fall back to baseline rendering. For the resolver contract, see
@@ -504,12 +562,12 @@ For the full locale model, see
**Integration category:** Required for first integration
-Use the server SDK result for the content decision that starts the route. The resolver is local and
-synchronous: it joins the current `selectedOptimizations` with optimization links already present in
-the Contentful payload.
+Use the request-bound server SDK result for the content decision that starts the route. The managed
+helper fetches the baseline entry, then joins the current `selectedOptimizations` with optimization
+links in that Contentful payload.
-1. Fetch Contentful entries and request Optimization data in the same Server Component render.
-2. Call `optimization.resolveOptimizedEntry()` for each baseline entry.
+1. Request Optimization data in the Server Component render.
+2. Call `requestOptimization.fetchOptimizedEntry(entryId)` for each entry.
3. Render the resolved entry outside an owned `OptimizationRoot` boundary when the initial HTML must
contain personalized content.
4. Treat missing optimization data, unresolved links, all-locale CDA payloads, and API failures as
@@ -521,8 +579,7 @@ the Contentful payload.
// app/page.tsx
import { HybridEntryList } from '@/components/HybridEntryList'
import { HybridTakeoverBoundary } from '@/components/HybridTakeoverBoundary'
-import { fetchEntriesFromContentful } from '@/lib/contentful-client'
-import { APP_LOCALE, getOptimizationData, optimization } from '@/lib/optimization-server'
+import { APP_LOCALE, getOptimizationRequest, optimization } from '@/lib/optimization-server'
import type { Entry } from 'contentful'
function ServerEntryList({ entries }: { entries: Entry[] }) {
@@ -536,16 +593,19 @@ function ServerEntryList({ entries }: { entries: Entry[] }) {
}
export default async function Home() {
- const [baselineEntries, optimizationData] = await Promise.all([
- fetchEntriesFromContentful(['home-hero', 'home-offer']),
- getOptimizationData(),
- ])
-
- // Resolve locally against request-selected optimizations, with baseline fallback.
- const serverResolvedData = baselineEntries.map((baselineEntry) =>
- optimization.resolveOptimizedEntry(baselineEntry, optimizationData?.selectedOptimizations),
+ const entryIds = ['home-hero', 'home-offer']
+ const optimizationRequest = await getOptimizationRequest()
+ const optimizationData = optimizationRequest?.optimizationData
+
+ // Fetch baseline entries and resolve them against request-selected optimizations.
+ const serverResults = await Promise.all(
+ entryIds.map((entryId) =>
+ optimizationRequest
+ ? optimizationRequest.requestOptimization.fetchOptimizedEntry(entryId)
+ : optimization.fetchOptimizedEntry(entryId, { selectedOptimizations: [] }),
+ ),
)
- const serverEntries = serverResolvedData.map(({ entry }) => entry)
+ const serverEntries = serverResults.map(({ entry }) => entry)
const defaults = { consent: true }
return (
@@ -556,7 +616,7 @@ export default async function Home() {
serverContent={}
serverOptimizationState={optimizationData}
>
-
+
)
}
@@ -571,11 +631,11 @@ same `data-ctfl-*` metadata after browser startup.
```tsx
import { ServerOptimizedEntry } from '@contentful/optimization-nextjs/server'
-function ServerRenderedEntry({ baselineEntry, resolvedData }: ServerRenderedEntryProps) {
+function ServerRenderedEntry({ result }: ServerRenderedEntryProps) {
return (
// Render data-ctfl-* attributes for browser entry-interaction tracking.
-
- {String(resolvedData.entry.fields.title ?? '')}
+
+ {String(result.entry.fields.title ?? '')}
)
}
@@ -598,8 +658,25 @@ server-resolved content outside that provider boundary and pass server-returned
4. Mount `OptimizationRoot` around the Client Components that take over after state handoff.
5. Pass browser-exposed values such as `NEXT_PUBLIC_CONTENTFUL_OPTIMIZATION_CLIENT_ID` into the
client provider.
-6. Include `defaults.consent: true` only when application policy permits accepted browser startup.
-7. Wrap `NextAppAutoPageTracker` in `Suspense` because it uses App Router navigation hooks.
+6. Pass the browser-safe app-owned `contentful.js` client through `contentful` when takeover entries
+ use `entryId`.
+7. Include `defaults.consent: true` only when application policy permits accepted browser startup.
+8. Wrap `NextAppAutoPageTracker` in `Suspense` because it uses App Router navigation hooks.
+
+**Copy this:**
+
+```ts
+// lib/contentful-browser-client.ts
+import { createClient } from 'contentful'
+
+export const contentfulClient = createClient({
+ accessToken: process.env.NEXT_PUBLIC_CONTENTFUL_DELIVERY_TOKEN ?? '',
+ environment: process.env.NEXT_PUBLIC_CONTENTFUL_ENVIRONMENT ?? 'main',
+ space: process.env.NEXT_PUBLIC_CONTENTFUL_SPACE_ID ?? '',
+})
+```
+
+Use only browser-safe Delivery API credentials in `NEXT_PUBLIC_` variables.
**Adapt this to your use case:**
@@ -612,6 +689,7 @@ import {
type NextAppAutoPageTrackerProps,
type OptimizationRootProps,
} from '@contentful/optimization-nextjs/client'
+import { contentfulClient } from '@/lib/contentful-browser-client'
import { Suspense, useState, type ReactNode } from 'react'
export function HybridTakeoverBoundary({
@@ -638,6 +716,10 @@ export function HybridTakeoverBoundary({
clientId={process.env.NEXT_PUBLIC_CONTENTFUL_OPTIMIZATION_CLIENT_ID ?? ''}
environment={process.env.NEXT_PUBLIC_CONTENTFUL_OPTIMIZATION_ENVIRONMENT ?? 'main'}
locale={locale}
+ contentful={{
+ client: contentfulClient,
+ defaultQuery: { include: 10, locale },
+ }}
defaults={defaults}
serverOptimizationState={serverOptimizationState}
onStatesReady={() => {
@@ -670,12 +752,15 @@ Render takeover content with React Web entry primitives from the Next.js client
when visible content must react to profile changes.
1. Create Client Components for entries that need browser-side re-resolution.
-2. Pass the baseline Contentful entry into `OptimizedEntry`.
+2. Use `entryId` when browser `OptimizationRoot` has `contentful: { client }`.
3. Pass `liveUpdates={true}` for entries that must update after `identify()`, `consent()`,
`reset()`, preview changes, or selected-optimization state changes.
4. Use the server-resolved entry as `loadingFallback` when you need the first rendered content to
stay stable while the browser SDK becomes ready.
-5. Use `useOptimizedEntry()` or `useEntryResolver()` only when a component needs custom rendering
+5. Use `errorFallback` and `onEntryError` when managed CDA failures must keep server content visible
+ and notify application-owned logging.
+6. Pass `baselineEntry` only when the takeover component receives entries fetched outside the SDK.
+7. Use `useOptimizedEntry()` or `useEntryResolver()` only when a component needs custom rendering
control that the `OptimizedEntry` wrapper does not provide.
**Adapt this to your use case:**
@@ -686,24 +771,31 @@ when visible content must react to profile changes.
import { OptimizedEntry } from '@contentful/optimization-nextjs/client'
import type { Entry } from 'contentful'
+function reportContentfulEntryError(error: unknown) {
+ // Send this to your app-owned logging or monitoring pipeline.
+ console.error(error)
+}
+
function EntryCard({ entry }: { entry: Entry }) {
return {String(entry.fields.title ?? '')}
}
export function HybridEntry({
- baselineEntry,
+ entryId,
serverResolvedEntry,
}: {
- baselineEntry: Entry
+ entryId: string
serverResolvedEntry: Entry
}) {
// liveUpdates keeps this entry reactive after identify, consent, reset, or preview changes.
// loadingFallback preserves server-selected content while the browser SDK initializes.
return (
}
liveUpdates={true}
loadingFallback={}
+ onEntryError={(error) => reportContentfulEntryError(error)}
>
{(resolvedEntry) => }
@@ -711,6 +803,16 @@ export function HybridEntry({
}
```
+If a Client Component already receives a manually fetched baseline entry, keep the manual prop:
+
+**Adapt this to your use case:**
+
+```tsx
+}>
+ {(resolvedEntry) => }
+
+```
+
For shared live-update controls, set `liveUpdates={true}` on `OptimizationRoot` or wrap a subtree in
`LiveUpdatesProvider`. Per-entry `liveUpdates={true}` or `liveUpdates={false}` overrides the global
setting for that component.
@@ -1114,18 +1216,20 @@ your privacy, analytics, and platform owners agree on the event posture.
**Adapt this to your use case:**
```ts
-export async function getOptimizationData() {
+export async function getOptimizationRequest() {
const [cookieStore, headerStore] = await Promise.all([cookies(), headers()])
const granted = cookieStore.get('app-personalization-consent')?.value === 'granted'
if (!granted) return undefined
- return await getNextjsServerOptimizationData(optimization, {
+ const { data, requestOptimization } = await getNextjsServerOptimizationData(optimization, {
consent: { events: true, persistence: true },
cookies: cookieStore,
headers: headerStore,
locale: APP_LOCALE,
})
+
+ return { optimizationData: data, requestOptimization }
}
```
diff --git a/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr.md b/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr.md
index c21923139..e9ed6e8cf 100644
--- a/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr.md
+++ b/documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr.md
@@ -18,12 +18,13 @@ without profile persistence. Use this path only when your application policy per
server page call at first load. If consent depends on a consent management platform (CMP), account
preference, or regional rule, use the policy-dependent consent section before release.
-1. Install the Next.js adapter package.
+1. Install the Next.js adapter package. Add `contentful` only if your app does not already have a
+ Contentful Delivery API client.
**Copy this:**
```sh
- pnpm add @contentful/optimization-nextjs
+ pnpm add @contentful/optimization-nextjs contentful
```
2. Create one server SDK singleton.
@@ -33,24 +34,37 @@ preference, or regional rule, use the policy-dependent consent section before re
```ts
// lib/optimization-server.ts
import { createNextjsOptimization } from '@contentful/optimization-nextjs/server'
+ import { createClient } from 'contentful'
export const APP_LOCALE = 'en-US'
+ const contentfulClient = createClient({
+ accessToken: process.env.CONTENTFUL_DELIVERY_TOKEN ?? '',
+ environment: process.env.CONTENTFUL_ENVIRONMENT ?? 'main',
+ space: process.env.CONTENTFUL_SPACE_ID ?? '',
+ })
+
// Keep one server SDK instance; bind request state through adapter helpers.
export const optimization = createNextjsOptimization({
clientId: process.env.CONTENTFUL_OPTIMIZATION_CLIENT_ID ?? '',
+ contentful: {
+ client: contentfulClient,
+ defaultQuery: {
+ // Managed fetching expects one CDA locale and resolved optimization links.
+ include: 10,
+ locale: APP_LOCALE,
+ },
+ },
environment: process.env.CONTENTFUL_OPTIMIZATION_ENVIRONMENT ?? 'main',
locale: APP_LOCALE,
logLevel: 'error',
})
```
-3. Fetch one Contentful entry in a Server Component, resolve it with request-local Optimization
- data, and render the resolved entry.
+3. Fetch and resolve one Contentful entry in a Server Component with the request-bound SDK helper,
+ then render the result.
- In this snippet, `fetchEntryFromContentful()` is an app-owned Contentful CDA helper. It must
- return one single-locale entry with linked optimization entries and variants included. The
- `cookieStore` and `headerStore` values come from Next.js `cookies()` and `headers()`.
+ The `cookieStore` and `headerStore` values come from Next.js `cookies()` and `headers()`.
`` is valid when this page renders under SDK context provided by
`OptimizationRoot` or `OptimizationProvider`, such as a shared App Router layout. If you have not
added that provider yet, omit the marker until you complete the client provider section.
@@ -61,39 +75,35 @@ preference, or regional rule, use the policy-dependent consent section before re
// app/page.tsx
import { APP_LOCALE, optimization } from '@/lib/optimization-server'
import { NextjsOptimizationState } from '@contentful/optimization-nextjs/client'
- import { getNextjsServerOptimizationData } from '@contentful/optimization-nextjs/server'
+ import {
+ ServerOptimizedEntry,
+ getNextjsServerOptimizationData,
+ } from '@contentful/optimization-nextjs/server'
import { cookies, headers } from 'next/headers'
export default async function Home() {
- const [cookieStore, headerStore, baselineEntry] = await Promise.all([
- cookies(),
- headers(),
- fetchEntryFromContentful({
- entryId: 'homepage-hero',
- include: 10,
- locale: APP_LOCALE,
- }),
- ])
+ const [cookieStore, headerStore] = await Promise.all([cookies(), headers()])
// Bind request state to the server page call without durable profile persistence.
- const { data: optimizationData } = await getNextjsServerOptimizationData(optimization, {
- consent: { events: true, persistence: false },
- cookies: cookieStore,
- headers: headerStore,
- locale: APP_LOCALE,
- })
-
- // The resolver returns the baseline entry when no selected optimization matches.
- const resolvedData = optimization.resolveOptimizedEntry(
- baselineEntry,
- optimizationData?.selectedOptimizations,
+ const { data: optimizationData, requestOptimization } = await getNextjsServerOptimizationData(
+ optimization,
+ {
+ consent: { events: true, persistence: false },
+ cookies: cookieStore,
+ headers: headerStore,
+ locale: APP_LOCALE,
+ },
)
- const resolvedEntry = resolvedData.entry
+
+ // Fetch the baseline entry and resolve it against request-selected optimizations.
+ const result = await requestOptimization.fetchOptimizedEntry('homepage-hero')
return (
- {String(resolvedEntry.fields.title ?? '')}
+
+ {String(result.entry.fields.title ?? '')}
+
)
}
@@ -138,7 +148,7 @@ Use this table as the setup inventory for the full SSR integration:
| Next.js App Router with React and React DOM peer dependencies | Required for first integration | Yes | Application `package.json` |
| `@contentful/optimization-nextjs` package | Required for first integration | Yes | Application package manager |
| Optimization client ID and environment | Required for first integration | Yes | Server SDK config and `OptimizationRoot` props for browser integrations |
-| Contentful CDA credentials and app-owned fetcher | Required for first integration | Yes | Application Contentful client |
+| Contentful CDA credentials and app-owned `contentful.js` client | Required for first integration | Yes | Server SDK `contentful` config |
| Single-locale CDA entries with resolved optimization links | Required for first integration | Yes | CDA calls with `include: 10` and one `locale` |
| Server Component entry resolution | Required for first integration | Yes | App Router pages and server components |
| Next.js proxy or middleware hook | Common but policy-dependent | No | `proxy.ts` or `middleware.ts` |
@@ -152,10 +162,12 @@ Use this table as the setup inventory for the full SSR integration:
| Production caching and duplicate-event policy | Advanced or production-only | No | Next.js route config, server helper structure, and tracker settings |
| Client-side entry re-resolution, live updates, or preview takeover | Advanced or production-only | No | Use the hybrid pattern instead of this SSR guide |
-The application owns Contentful fetching, locale selection, route policy, consent policy, identity
-policy, and component rendering. The Next.js adapter owns SDK composition: the server entry
-delegates to the stateless Node SDK, the client entry delegates to the React Web SDK, and the
-request handler forwards sanitized request context headers for Server Components.
+The application owns the `contentful.js` client, credentials, locale selection, route policy,
+consent policy, identity policy, and component rendering. Preferred server rendering configures that
+client on `createNextjsOptimization()`, then request-bound helpers call
+`requestOptimization.fetchOptimizedEntry(entryId)` to fetch the baseline entry and resolve it with
+request-local selected optimizations. Manual `baselineEntry` plus `resolveOptimizedEntry()` remains
+supported when an existing server flow fetches entries outside the SDK.
## Core integration
@@ -217,44 +229,58 @@ For deeper consent mechanics, see
**Integration category:** Required for first integration
-The SDK does not fetch Contentful entries. Your application fetches the baseline entries, including
-linked optimization entries and variants, then passes the baseline entry and request-local
-`selectedOptimizations` into `resolveOptimizedEntry()`.
+Preferred server rendering uses the app-owned `contentful.js` client configured on
+`createNextjsOptimization()`. The request-bound helper fetches the baseline entry and resolves it
+with request-local `selectedOptimizations`.
-1. Fetch Contentful entries with one application Contentful locale.
-2. Use enough include depth for `nt_experiences`, their configuration, and `nt_variants`; the
- reference implementation uses `include: 10`.
+1. Configure `contentful: { client, defaultQuery }` on the server SDK singleton.
+2. Use one application Contentful locale and enough include depth for `nt_experiences`, their
+ configuration, and `nt_variants`; the reference implementation uses `include: 10`.
3. Call `getNextjsServerOptimizationData()` with the same request cookies, headers, consent, and
locale policy that apply to the rendered response.
-4. Pass `optimizationData?.selectedOptimizations` to `optimization.resolveOptimizedEntry()`.
-5. Render the returned `entry`. If no optimization data or matching variant is available, the
- resolver returns the baseline entry.
+4. Call `requestOptimization.fetchOptimizedEntry(entryId)`.
+5. Render the returned `result.entry` directly or pass `result` to `ServerOptimizedEntry`. If no
+ optimization data or matching variant is available, the helper returns the baseline entry.
In this example, `cookieStore` and `headerStore` are the values returned by Next.js `cookies()` and
-`headers()`. `fetchEntriesFromContentful()` is an app-owned CDA helper that must return
-single-locale entries with linked optimization entries and variants included.
+`headers()`.
**Adapt this to your use case:**
```tsx
const appConsent = cookieStore.get('app-personalization-consent')?.value === 'granted'
-const [baselineEntries, optimizationData] = await Promise.all([
- fetchEntriesFromContentful({ include: 10, locale: APP_LOCALE }),
- // Only request Optimization data when app policy permits profile-producing calls.
- appConsent
- ? getNextjsServerOptimizationData(optimization, {
- consent: { events: true, persistence: true },
- cookies: cookieStore,
- headers: headerStore,
- locale: APP_LOCALE,
- }).then(({ data }) => data)
- : undefined,
-])
-
-const resolvedEntries = baselineEntries.map((entry) =>
- // The resolver returns the baseline entry when no selected optimization matches.
- optimization.resolveOptimizedEntry(entry, optimizationData?.selectedOptimizations),
+// Only request Optimization data when app policy permits profile-producing calls.
+const optimizationRequest = appConsent
+ ? await getNextjsServerOptimizationData(optimization, {
+ consent: { events: true, persistence: true },
+ cookies: cookieStore,
+ headers: headerStore,
+ locale: APP_LOCALE,
+ })
+ : undefined
+
+const result = optimizationRequest
+ ? await optimizationRequest.requestOptimization.fetchOptimizedEntry('homepage-hero')
+ : await optimization.fetchOptimizedEntry('homepage-hero', {
+ selectedOptimizations: [],
+ })
+```
+
+When an existing flow already fetches Contentful entries, keep the manual resolver:
+
+**Adapt this to your use case:**
+
+```tsx
+const baselineEntry = await fetchEntryFromContentful({
+ entryId: 'homepage-hero',
+ include: 10,
+ locale: APP_LOCALE,
+})
+
+const resolvedData = optimization.resolveOptimizedEntry(
+ baselineEntry,
+ optimizationRequest?.data?.selectedOptimizations,
)
```
@@ -408,16 +434,17 @@ export function OptimizationControls() {
The browser client can automatically observe server-rendered entry wrappers when the markup contains
the `data-ctfl-*` tracking attributes. Use `ServerOptimizedEntry` to render those attributes from
-the same baseline entry and resolved data used for SSR content.
+the same managed result, or manual baseline entry and resolved data, used for SSR content.
1. Wrap server-rendered entry content with `ServerOptimizedEntry`.
-2. Pass the original baseline entry and the full `ResolvedData` returned by
+2. Pass the `result` returned by `requestOptimization.fetchOptimizedEntry(entryId)`.
+3. For manual fetching, pass the original baseline entry and the full `ResolvedData` returned by
`resolveOptimizedEntry()`.
-3. Use `getServerTrackingAttributes()` from `@contentful/optimization-nextjs/tracking-attributes`
+4. Use `getServerTrackingAttributes()` from `@contentful/optimization-nextjs/tracking-attributes`
when an existing server-rendered element or design-system component must own the wrapper markup.
-4. Use `trackEntryInteraction` on `OptimizationRoot` only to opt out of interaction types the app
+5. Use `trackEntryInteraction` on `OptimizationRoot` only to opt out of interaction types the app
must not observe.
-5. Use `clickable`, `trackViews`, `trackClicks`, `trackHovers`, and duration interval props only
+6. Use `clickable`, `trackViews`, `trackClicks`, `trackHovers`, and duration interval props only
when an entry needs per-element tracking behavior.
**Adapt this to your use case:**
@@ -425,13 +452,12 @@ the same baseline entry and resolved data used for SSR content.
```tsx
- {resolvedData.entry.fields.title}
+ {result.entry.fields.title}
```
diff --git a/documentation/guides/integrating-the-react-native-sdk-in-a-react-native-app.md b/documentation/guides/integrating-the-react-native-sdk-in-a-react-native-app.md
index f34d337b2..3e0679015 100644
--- a/documentation/guides/integrating-the-react-native-sdk-in-a-react-native-app.md
+++ b/documentation/guides/integrating-the-react-native-sdk-in-a-react-native-app.md
@@ -6,8 +6,8 @@ optional preview tooling to a React Native or Expo application with
The React Native SDK builds on the Optimization Core SDK and adds React Native providers, hooks,
entry rendering, viewport and tap tracking, AsyncStorage persistence, optional offline delivery, and
-an in-app preview panel. Your application still owns Contentful Delivery API fetching, consent
-policy, identity policy, navigation, and final rendering.
+an in-app preview panel. Your application still owns Contentful Delivery API client configuration,
+Contentful locale policy, consent policy, identity policy, navigation, and final rendering.
## Quick start
@@ -24,21 +24,19 @@ events or rendering personalized content.
pnpm add @contentful/optimization-react-native @react-native-async-storage/async-storage contentful
```
-2. Mount `OptimizationRoot`, emit one screen event for profile context, fetch one single-locale
- Contentful entry with linked optimization data, and render the resolved entry ID through
- `OptimizedEntry`.
+2. Mount `OptimizationRoot` with your app-owned Contentful client, emit one screen event for profile
+ context, and render one single-locale Contentful entry by ID through `OptimizedEntry`.
**Copy this:**
```tsx
- import { useEffect, useState } from 'react'
import { Text } from 'react-native'
import {
OptimizationRoot,
OptimizedEntry,
useScreenTracking,
} from '@contentful/optimization-react-native'
- import { createClient, type Entry } from 'contentful'
+ import { createClient } from 'contentful'
const APP_LOCALE = 'en-US'
@@ -49,25 +47,12 @@ events or rendering personalized content.
})
function HomeScreen() {
- const [entry, setEntry] = useState()
-
// Automatic screen tracking uses current-screen dedupe.
useScreenTracking({ name: 'Home' })
- useEffect(() => {
- void contentfulClient
- .getEntry('hero-entry-id', {
- include: 10, // Resolve optimization and variant links before SDK resolution.
- locale: APP_LOCALE, // Keep CDA entries and SDK context on the same locale.
- })
- .then(setEntry)
- }, [])
-
- if (!entry) return null
-
// OptimizedEntry passes the selected variant or baseline fallback to the renderer.
return (
-
+
{(resolvedEntry) => {`Resolved entry: ${resolvedEntry.sys.id}`}}
)
@@ -80,6 +65,13 @@ events or rendering personalized content.
clientId="your-optimization-client-id"
environment="main"
locale={APP_LOCALE}
+ contentful={{
+ client: contentfulClient,
+ defaultQuery: {
+ include: 10, // Resolve optimization and variant links before SDK resolution.
+ locale: APP_LOCALE, // Keep CDA entries and SDK context on the same locale.
+ },
+ }}
defaults={{ consent: true }}
>
@@ -125,33 +117,34 @@ events or rendering personalized content.
Use this setup inventory before you move beyond the quick start:
-| Setup item | Category | Required for quick start | Where to configure |
-| ------------------------------------------------------------------------- | ------------------------------ | ------------------------ | -------------------------------------------------------------------------------------- |
-| React Native app with compatible React and React Native peer dependencies | Required for first integration | Yes | Application package dependencies |
-| `@contentful/optimization-react-native` package | Required for first integration | Yes | Application package dependencies |
-| `@react-native-async-storage/async-storage` peer dependency | Required for first integration | Yes | Application package dependencies and native install flow |
-| Contentful Delivery API client | Required for first integration | Yes | Application package dependencies and app-owned Contentful client factory |
-| Optimization client ID and environment | Required for first integration | Yes | `OptimizationRoot`, `OptimizationProvider`, or `ContentfulOptimization.create(...)` |
-| Experience API and Insights API endpoint overrides | Common but policy-dependent | No | `api` SDK config for staging, mock, or non-default hosts |
-| Contentful space, environment, access token, and CDA host | Required for first integration | Yes | Application-owned Contentful fetching layer |
-| Optimized Contentful entries with resolved `nt_experiences` and variants | Required for first integration | Yes | Contentful content model and CDA `include` depth |
-| Single Contentful CDA locale and SDK Experience/event locale | Required for first integration | Yes | App locale policy, Contentful `getEntry()` calls, and SDK `locale` |
-| `OptimizationRoot` mounted once around SDK consumers | Required for first integration | Yes | React Native app root or navigation root |
-| Screen, route, or lifecycle integration | Required for first integration | Yes | `useScreenTracking`, `useScreenTrackingCallback`, or `OptimizationNavigationContainer` |
-| Entry rendering through `OptimizedEntry` or `useEntryResolver` | Required for first integration | Yes | React Native components that render Contentful entries |
-| Consent startup policy and user-choice wiring | Common but policy-dependent | Conditional | SDK `defaults`, `allowedEventTypes`, and application consent UI or CMP callbacks |
-| Entry view and tap tracking policy | Common but policy-dependent | Conditional | `trackEntryInteraction` on `OptimizationRoot` and per-entry tracking props |
-| User identity, profile continuity, and reset policy | Common but policy-dependent | No | Authentication, account, or settings flows that call `identify()` and `reset()` |
-| React Navigation integration | Optional | No | App navigation dependencies and `OptimizationNavigationContainer` |
-| `@react-native-community/netinfo` for offline detection | Optional | No | Application package dependencies and native install flow |
-| Preview peer dependencies | Optional | No | `@react-native-clipboard/clipboard` and `react-native-safe-area-context` |
-| Merge tag and Custom Flag rendering | Optional | No | App-owned Rich Text, flag, or feature-rendering components |
-| Analytics forwarding destination | Optional | No | `onStatesReady` subscriptions and application-owned analytics code |
-| Strict pre-consent allowlist, queue policy, and diagnostics | Advanced or production-only | No | SDK `allowedEventTypes`, `queuePolicy`, `onEventBlocked`, and `logLevel` |
-| Preview release gating and custom native builds | Advanced or production-only | No | Build flags, Expo custom dev builds, and release configuration |
-
-The React Native SDK does not fetch Contentful entries. Fetch entries in your application layer,
-then pass single-locale entry objects to SDK components and hooks.
+| Setup item | Category | Required for quick start | Where to configure |
+| ------------------------------------------------------------------------------------ | ------------------------------ | ------------------------ | -------------------------------------------------------------------------------------- |
+| React Native app with compatible React and React Native peer dependencies | Required for first integration | Yes | Application package dependencies |
+| `@contentful/optimization-react-native` package | Required for first integration | Yes | Application package dependencies |
+| `@react-native-async-storage/async-storage` peer dependency | Required for first integration | Yes | Application package dependencies and native install flow |
+| Contentful Delivery API client | Required for first integration | Yes | Application package dependencies, app-owned Contentful client factory, and SDK config |
+| Optimization client ID and environment | Required for first integration | Yes | `OptimizationRoot`, `OptimizationProvider`, or `ContentfulOptimization.create(...)` |
+| Experience API and Insights API endpoint overrides | Common but policy-dependent | No | `api` SDK config for staging, mock, or non-default hosts |
+| Contentful space, environment, access token, and CDA host | Required for first integration | Yes | Application-owned Contentful client and SDK `contentful` config |
+| Optimized Contentful entries with resolved `nt_experiences` and variants | Required for first integration | Yes | Contentful content model and CDA `include` depth |
+| Single Contentful CDA locale and SDK Experience/event locale | Required for first integration | Yes | App locale policy, SDK `contentful.defaultQuery` or `entryQuery`, and SDK `locale` |
+| `OptimizationRoot` mounted once around SDK consumers | Required for first integration | Yes | React Native app root or navigation root |
+| Screen, route, or lifecycle integration | Required for first integration | Yes | `useScreenTracking`, `useScreenTrackingCallback`, or `OptimizationNavigationContainer` |
+| Entry rendering through `OptimizedEntry`, `useOptimizedEntry`, or `useEntryResolver` | Required for first integration | Yes | React Native components that render Contentful entries |
+| Consent startup policy and user-choice wiring | Common but policy-dependent | Conditional | SDK `defaults`, `allowedEventTypes`, and application consent UI or CMP callbacks |
+| Entry view and tap tracking policy | Common but policy-dependent | Conditional | `trackEntryInteraction` on `OptimizationRoot` and per-entry tracking props |
+| User identity, profile continuity, and reset policy | Common but policy-dependent | No | Authentication, account, or settings flows that call `identify()` and `reset()` |
+| React Navigation integration | Optional | No | App navigation dependencies and `OptimizationNavigationContainer` |
+| `@react-native-community/netinfo` for offline detection | Optional | No | Application package dependencies and native install flow |
+| Preview peer dependencies | Optional | No | `@react-native-clipboard/clipboard` and `react-native-safe-area-context` |
+| Merge tag and Custom Flag rendering | Optional | No | App-owned Rich Text, flag, or feature-rendering components |
+| Analytics forwarding destination | Optional | No | `onStatesReady` subscriptions and application-owned analytics code |
+| Strict pre-consent allowlist, queue policy, and diagnostics | Advanced or production-only | No | SDK `allowedEventTypes`, `queuePolicy`, `onEventBlocked`, and `logLevel` |
+| Preview release gating and custom native builds | Advanced or production-only | No | Build flags, Expo custom dev builds, and release configuration |
+
+Prefer SDK-managed entry fetching by configuring `contentful: { client }` and passing `entryId`.
+Manual single-locale `baselineEntry` objects remain supported when your application layer must own
+the individual Contentful request.
## Core integration
@@ -219,7 +212,7 @@ accepted.
5. Subscribe to `states.blockedEventStream` during development when you need to verify blocked
calls.
-**Copy this:**
+**Adapt this to your use case:**
```tsx
// Use this only when policy allows Optimization to start accepted.
@@ -260,27 +253,52 @@ For cross-SDK policy details, see
**Integration category:** Required for first integration
-Your app owns Contentful fetching. The SDK resolver expects a standard single-locale Contentful CDA
-entry payload where optimized fields are direct values, not locale-keyed maps.
+Your app owns the Contentful client and locale policy. The React Native SDK can call that client
+when `contentful: { client }` is configured, or your app can fetch a manual `baselineEntry`. Both
+paths must produce a standard single-locale Contentful CDA entry payload where optimized fields are
+direct values, not locale-keyed maps.
1. Choose the application Contentful locale in your app configuration, i18n layer, or navigation
layer.
-2. Pass that locale to Contentful CDA requests that feed SDK entry resolution.
+2. Configure `contentful.defaultQuery` on the SDK, pass per-entry `entryQuery`, or pass the locale
+ to manual Contentful CDA requests.
3. Request enough link depth for `nt_experiences`, optimization config, and linked variant entries.
`include: 10` is the repository reference implementation's pattern.
4. Pass the same locale to SDK `locale` when Experience API responses and event context must use the
same language.
5. Do not pass `contentful.js` `withAllLocales` results or raw CDA `locale=*` responses to
- `OptimizedEntry` or `useEntryResolver`.
+ `OptimizedEntry`, `useOptimizedEntry`, or `useEntryResolver`.
-**Copy this:**
+**Adapt this to your use case:**
```tsx
const APP_LOCALE = 'en-US'
+
+
+ {(resolvedEntry) => }
+
+
+```
+
+Manual fetching remains supported when your app needs request ownership around one entry:
+
+**Adapt this to your use case:**
+
+```tsx
const entry = await contentfulClient.getEntry('hero-entry-id', {
- include: 10, // Resolve optimization and variant links before SDK resolution.
- locale: APP_LOCALE, // Keep CDA entries and SDK context on the same locale.
+ include: 10,
+ locale: APP_LOCALE,
})
```
@@ -302,27 +320,49 @@ For the broader locale model, see
non-optimized entries through unchanged. Invalid, incomplete, or unmatched optimization data falls
back to the baseline entry instead of throwing.
-1. Pass the baseline Contentful entry to `OptimizedEntry`.
-2. Use a render prop when the child needs the resolved baseline or variant entry.
-3. Use static children only when you need entry tracking but not variant data in the child.
-4. Use `useEntryResolver()` when a component needs the same resolution behavior without the wrapper
+1. Pass `entryId` to let the SDK fetch the baseline entry through the configured Contentful client.
+2. Pass `baselineEntry` when your application already fetched the entry and must keep manual request
+ ownership.
+3. Use `loadingFallback`, `errorFallback`, and `onEntryError` when the managed fetch needs visible
+ loading or error handling.
+4. Use a render prop when the child needs the resolved baseline or variant entry.
+5. Use static children only when you need entry tracking but not variant data in the child.
+6. Use `useOptimizedEntry()` for the same managed or manual source model without the wrapper
component.
+7. Use `useEntryResolver()` when a component needs manual-only resolution helpers.
**Adapt this to your use case:**
```tsx
-import { OptimizedEntry, useEntryResolver } from '@contentful/optimization-react-native'
+import {
+ OptimizedEntry,
+ useEntryResolver,
+ useOptimizedEntry,
+} from '@contentful/optimization-react-native'
import type { Entry } from 'contentful'
-function HeroSection({ baselineEntry }: { baselineEntry: Entry }) {
+function HeroSection() {
return (
-
+ diagnostics.report(error)}
+ >
{(resolvedEntry) => }
)
}
-function HeroData({ baselineEntry }: { baselineEntry: Entry }) {
+function HeroData() {
+ const { entry, isReady } = useOptimizedEntry({ entryId: 'hero-entry-id' })
+
+ if (!isReady || !entry) return null
+
+ return
+}
+
+function HeroManualData({ baselineEntry }: { baselineEntry: Entry }) {
const { resolveEntry } = useEntryResolver()
// resolveEntry uses current selected optimizations and falls back to baseline content.
const resolvedEntry = resolveEntry(baselineEntry)
diff --git a/documentation/guides/integrating-the-react-web-sdk-in-a-react-app.md b/documentation/guides/integrating-the-react-web-sdk-in-a-react-app.md
index eeb8100f9..65a04e7cd 100644
--- a/documentation/guides/integrating-the-react-web-sdk-in-a-react-app.md
+++ b/documentation/guides/integrating-the-react-web-sdk-in-a-react-app.md
@@ -4,8 +4,8 @@ Use this guide when you want to add browser-side personalization and analytics t
application with `@contentful/optimization-react-web`.
The React Web SDK wraps `@contentful/optimization-web` with React providers, hooks, entry-rendering
-components, live-update state, and router adapters. Your application still owns Contentful entry
-fetching, consent policy, identity policy, routing, and final rendering.
+components, live-update state, and router adapters. Your application still owns the Contentful
+client and credentials, consent policy, identity policy, routing, and final rendering.
Use the lower-level Web SDK guide instead when your app is not React-based or when you want to own
the browser SDK lifecycle without React abstractions.
@@ -24,9 +24,8 @@ explicit opt-in, wire the consent section before you emit events or render perso
pnpm add @contentful/optimization-react-web contentful
```
-2. Mount `OptimizationRoot` once, emit a page event so the SDK can evaluate route-based
- optimizations, fetch one single-locale Contentful entry with linked optimization data, and render
- it through `OptimizedEntry`.
+2. Mount `OptimizationRoot` once with your app-owned `contentful.js` client, emit a page event so
+ the SDK can evaluate route-based optimizations, and render one entry through `OptimizedEntry`.
Set `PUBLIC_HERO_ENTRY_ID` to the baseline entry ID for the first optimized entry, or replace
`HERO_ENTRY_ID` with an app-owned constant.
@@ -39,8 +38,8 @@ explicit opt-in, wire the consent section before you emit events or render perso
OptimizedEntry,
useOptimizationActions,
} from '@contentful/optimization-react-web'
- import { createClient, type Entry } from 'contentful'
- import { useEffect, useState } from 'react'
+ import { createClient } from 'contentful'
+ import { useEffect } from 'react'
const APP_LOCALE = 'en-US'
const HERO_ENTRY_ID = import.meta.env.PUBLIC_HERO_ENTRY_ID
@@ -54,27 +53,14 @@ explicit opt-in, wire the consent section before you emit events or render perso
function HomePage() {
const { page } = useOptimizationActions()
- const [entry, setEntry] = useState()
useEffect(() => {
// Emit after the provider is ready so the SDK can resolve route-based optimizations.
void page()
}, [page])
- useEffect(() => {
- void contentfulClient
- .getEntry(HERO_ENTRY_ID, {
- // Resolve linked optimization and variant entries before passing the entry to React Web.
- include: INCLUDE_DEPTH,
- locale: APP_LOCALE,
- })
- .then(setEntry)
- }, [])
-
- if (!entry) return null
-
return (
-
+
{(resolvedEntry) => (
{String(resolvedEntry.fields.title ?? '')}
@@ -90,6 +76,14 @@ explicit opt-in, wire the consent section before you emit events or render perso
clientId={import.meta.env.PUBLIC_CONTENTFUL_OPTIMIZATION_CLIENT_ID}
environment={import.meta.env.PUBLIC_CONTENTFUL_OPTIMIZATION_ENVIRONMENT ?? 'main'}
locale={APP_LOCALE}
+ contentful={{
+ client: contentfulClient,
+ defaultQuery: {
+ // Managed fetching expects one CDA locale and resolved optimization links.
+ include: INCLUDE_DEPTH,
+ locale: APP_LOCALE,
+ },
+ }}
// Use accepted startup consent only when your application policy permits it.
defaults={{ consent: true }}
>
@@ -110,7 +104,7 @@ explicit opt-in, wire the consent section before you emit events or render perso
- [Core integration](#core-integration)
- [Install and initialize the React provider](#install-and-initialize-the-react-provider)
- [Consent and privacy-policy handoff](#consent-and-privacy-policy-handoff)
- - [Contentful entry fetching and locale shape](#contentful-entry-fetching-and-locale-shape)
+ - [Contentful client configuration and locale shape](#contentful-client-configuration-and-locale-shape)
- [Entry resolution and fallback rendering](#entry-resolution-and-fallback-rendering)
- [Page events and route tracking](#page-events-and-route-tracking)
- [Entry interaction tracking](#entry-interaction-tracking)
@@ -140,10 +134,10 @@ Use this table as the setup inventory for the guide:
| `@contentful/optimization-react-web` plus app-owned React and React DOM peer dependencies | Required for first integration | Yes | Application package dependencies |
| Optimization client ID and environment | Required for first integration | Yes | `OptimizationRoot` props, usually from runtime environment variables |
| Experience API and Insights API endpoint overrides | Common but policy-dependent | No | `api` prop when using non-default production, staging, or mock endpoints |
-| Contentful Delivery API client, space, environment, and access token | Required for first integration | Yes | Application-owned Contentful fetching layer |
+| Contentful Delivery API client, space, environment, and access token | Required for first integration | Yes | Application-owned `contentful.js` client passed through `OptimizationRoot` |
| Contentful optimized entry ID used by the first rendered entry | Required for first integration | Yes | Runtime environment variable such as `PUBLIC_HERO_ENTRY_ID`, or an app-owned entry ID constant |
| Contentful entries with linked optimization and variant data | Required for first integration | Yes | Contentful content model and entries rendered by the app |
-| Single Contentful CDA locale and `include: 10` for optimized entries | Required for first integration | Yes | `getEntry()` or `getEntries()` calls before passing entries to the SDK |
+| Single Contentful CDA locale and `include: 10` for optimized entries | Required for first integration | Yes | `contentful.defaultQuery`, per-entry `entryQuery`, or manual `getEntry()` calls |
| `OptimizationRoot` mounted once around the React tree that uses SDK hooks | Required for first integration | Yes | React app root, layout, or router root |
| Page event emission on initial render, plus route changes for routed apps | Required for first integration | Yes | Router adapter under `OptimizationRoot`, or an app-owned `page()` effect |
| Entry rendering through `OptimizedEntry` or `useOptimizedEntry` | Required for first integration | Yes | React components that render Contentful entries |
@@ -157,8 +151,9 @@ Use this table as the setup inventory for the guide:
| Strict pre-consent event policy, cookie settings, queue policy, and CSP nonce | Advanced or production-only | No | `OptimizationRoot` config and preview-panel attach options |
| Externally owned Web SDK instance | Advanced or production-only | No | `OptimizationProvider sdk={...}` with `LiveUpdatesProvider` |
-The React Web SDK does not fetch Contentful entries. Fetch entries in your application layer, then
-pass the resulting single-locale entry objects to the SDK components and hooks.
+For the preferred JavaScript path, create the `contentful.js` client in your app and pass it to
+`OptimizationRoot` through `contentful: { client, defaultQuery?, cache? }`. Manual `baselineEntry`
+rendering remains supported when the app fetches entries outside the SDK.
## Core integration
@@ -284,16 +279,18 @@ default, the Web SDK permits only `identify` and `page` before consent is explic
For the cross-SDK policy model, see
[Consent management in the Optimization SDK Suite](../concepts/consent-management-in-the-optimization-sdk-suite.md).
-### Contentful entry fetching and locale shape
+### Contentful client configuration and locale shape
**Integration category:** Required for first integration
-The SDK resolves entries after your app fetches them from Contentful. It expects the standard
-single-locale CDA entry shape with direct field values, including linked optimization fields such as
-`fields.nt_experiences` and `fields.nt_variants`.
+The preferred React Web path uses an app-owned `contentful.js` client configured on
+`OptimizationRoot`. The SDK-managed entry fetch expects the standard single-locale CDA entry shape
+with direct field values, including linked optimization fields such as `fields.nt_experiences` and
+`fields.nt_variants`.
1. Choose the application Contentful locale in your router, i18n layer, or app configuration.
-2. Pass that locale to Contentful CDA requests.
+2. Pass that locale through `contentful.defaultQuery` on `OptimizationRoot`, per-entry `entryQuery`,
+ or manual CDA requests.
3. Pass the same locale to `OptimizationRoot` when Experience API responses and event context need
to match rendered content.
4. Fetch optimized entries with `include: 10` so linked optimization and variant entries are
@@ -301,9 +298,10 @@ single-locale CDA entry shape with direct field values, including linked optimiz
5. Do not pass `withAllLocales` or raw CDA `locale=*` responses to `OptimizedEntry`,
`useOptimizedEntry`, or `useEntryResolver()`.
-**Copy this:**
+**Adapt this to your use case:**
```tsx
+import { OptimizationRoot } from '@contentful/optimization-react-web'
import { createClient } from 'contentful'
const APP_LOCALE = 'en-US'
@@ -315,13 +313,20 @@ const contentfulClient = createClient({
space: import.meta.env.PUBLIC_CONTENTFUL_SPACE_ID,
})
-export async function fetchOptimizedEntry(entryId: string) {
- return await contentfulClient.getEntry(entryId, {
- // Resolve linked optimization and variant entries before rendering.
- include: INCLUDE_DEPTH,
- // Keep CDA locale aligned with the OptimizationRoot locale.
- locale: APP_LOCALE,
- })
+export function AppRoot() {
+ return (
+
+
+
+ )
}
```
@@ -335,16 +340,38 @@ needed, and rerender localized content. For the full locale model, see
**Integration category:** Required for first integration
-`OptimizedEntry` resolves a baseline Contentful entry against selected optimization state and
-renders either the selected variant or the baseline entry.
+`OptimizedEntry` resolves a Contentful entry against selected optimization state and renders either
+the selected variant or the baseline entry.
-1. Pass the baseline entry fetched by your application.
-2. Use a render prop when the rendered UI depends on the resolved entry.
-3. Use `loadingFallback` when you want temporary custom loading UI while optimization state is
+1. Use `entryId` for SDK-managed fetching when `OptimizationRoot` has `contentful: { client }`.
+2. Use `entryQuery` for per-entry query overrides such as a route-specific locale.
+3. Use `errorFallback` and `onEntryError` for managed CDA failures.
+4. Pass `baselineEntry` when the app fetches entries outside the SDK.
+5. Use a render prop when the rendered UI depends on the resolved entry.
+6. Use `loadingFallback` when you want temporary custom loading UI while optimization state is
unresolved.
-4. Use `useOptimizedEntry()` only when a component needs direct access to loading, readiness, or
+7. Use `useOptimizedEntry()` only when a component needs direct access to loading, readiness, or
selected-optimization metadata.
+Replace `reportContentfulEntryError` in the example with your app-owned logging or monitoring
+function.
+
+**Adapt this to your use case:**
+
+```tsx
+ }
+ loadingFallback={() => }
+ onEntryError={(error) => reportContentfulEntryError(error)}
+>
+ {(resolvedEntry) => }
+
+```
+
+For manual fetching, keep passing the app-fetched baseline entry:
+
**Adapt this to your use case:**
```tsx
diff --git a/documentation/guides/integrating-the-web-sdk-in-a-web-app.md b/documentation/guides/integrating-the-web-sdk-in-a-web-app.md
index b04a1d194..1bd25cdc1 100644
--- a/documentation/guides/integrating-the-web-sdk-in-a-web-app.md
+++ b/documentation/guides/integrating-the-web-sdk-in-a-web-app.md
@@ -22,8 +22,8 @@ events.
pnpm add @contentful/optimization-web contentful
```
-2. Create one Web SDK instance for the page or SPA runtime, then emit one `page()` event, fetch one
- single-locale Contentful entry, resolve the selected variant, and render it.
+2. Create one Contentful delivery client and one Web SDK instance for the page or SPA runtime, then
+ emit one `page()` event, fetch one optimized entry by ID, and render it.
**Adapt this to your use case:**
@@ -41,6 +41,11 @@ events.
const optimization = new ContentfulOptimization({
clientId: 'your-optimization-client-id',
+ contentful: {
+ client: contentfulClient,
+ // Include linked optimization entries and variants for SDK-managed entry fetches.
+ defaultQuery: { include: 10 },
+ },
environment: 'main',
locale: APP_LOCALE,
// Only use default-on consent when application policy permits it.
@@ -52,14 +57,8 @@ events.
})
// Emit the page event before resolving entries so selections are current.
- const pageResult = await optimization.page()
- const baselineEntry = await contentfulClient.getEntry('hero-entry-id', {
- include: 10,
- locale: APP_LOCALE,
- })
- // Passing [] falls back to the baseline when the page event is blocked or has no data.
- const selectedOptimizations = pageResult.accepted ? pageResult.data?.selectedOptimizations : []
- const { entry } = optimization.resolveOptimizedEntry(baselineEntry, selectedOptimizations ?? [])
+ await optimization.page()
+ const { entry } = await optimization.fetchOptimizedEntry('hero-entry-id')
const hero = document.querySelector('#hero')
if (hero) {
@@ -104,7 +103,7 @@ The full guide uses these setup items:
| Setup item | Category | Required for quick start | Where to configure |
| ------------------------------------------------------------------- | ------------------------------ | ------------------------ | ------------------------------------------------------------------------------------ |
| `@contentful/optimization-web` package | Required for first integration | Yes | Application package manager |
-| Contentful delivery client package | Required for first integration | Yes | Application package manager and Contentful client factory |
+| Contentful delivery client package | Required for first integration | Yes | Application package manager, Contentful client factory, and SDK `contentful.client` |
| Optimization client ID and optional non-`main` environment | Required for first integration | Yes | Runtime configuration passed to `new ContentfulOptimization(...)` |
| Contentful space, environment, and access token | Required for first integration | Yes | Application-owned Contentful client configuration |
| Non-default Contentful CDA host | Common but policy-dependent | No | Application-owned Contentful client host or endpoint configuration |
@@ -125,9 +124,9 @@ The full guide uses these setup items:
| Production event, privacy, and cache validation | Advanced or production-only | No | Release checklist, observability, and deployment configuration |
Keep the default path single-locale. Fetch entries for SDK resolution with one concrete Contentful
-locale and enough include depth for linked optimization entries and variants. Do not pass
-`contentful.js` `withAllLocales` results or raw CDA `locale=*` responses to
-`resolveOptimizedEntry()`.
+locale and enough include depth for linked optimization entries and variants. Do not configure
+SDK-managed fetches or manual fetches with `contentful.js` `withAllLocales` results or raw CDA
+`locale=*` responses.
## Core integration
@@ -175,6 +174,11 @@ export const contentfulClient = contentful.createClient({
// Reuse this singleton across route, render, and tracking handlers.
export const optimization = new ContentfulOptimization({
clientId: APP_CONFIG.optimizationClientId,
+ contentful: {
+ client: contentfulClient,
+ // Include linked optimization entries and variants for SDK-managed entry fetches.
+ defaultQuery: { include: 10 },
+ },
environment: APP_CONFIG.optimizationEnvironment,
locale: APP_LOCALE,
app: {
@@ -190,7 +194,9 @@ export const optimization = new ContentfulOptimization({
```
The Web SDK does not replace the Contentful delivery client. Your application still owns Contentful
-credentials, entry fetching, routing, rendering, consent policy, identity policy, and cache policy.
+credentials, delivery-client configuration, routing, rendering, consent policy, identity policy, and
+cache policy. When configured with `contentful: { client, defaultQuery?, cache? }`, the SDK can call
+that app-owned client's `getEntry()` method for managed entry fetching.
For locale mechanics, see
[Locale handling in the Optimization SDK Suite](../concepts/locale-handling-in-the-optimization-sdk-suite.md).
@@ -346,15 +352,15 @@ route.
**Integration category:** Required for first integration
-The browser app fetches Contentful entries. The Web SDK chooses the current variant after the
-baseline entry and Experience API selections exist.
+The browser app owns the Contentful delivery client, credentials, and delivery policy. The preferred
+path passes that app-owned `contentful.js` client to the SDK as
+`contentful: { client, defaultQuery?, cache? }`, then calls `fetchOptimizedEntry(entryId)` after
+`page()` or `identify()`. The stateful Web SDK uses current `selectedOptimizations` when omitted.
-1. Fetch the baseline Contentful entry with one CDA locale and enough include depth to resolve
- optimization entries and variants.
+1. Configure the Web SDK with `contentful: { client, defaultQuery?, cache? }`.
2. Call `page()` or `identify()` before rendering optimized content so SDK state has current
`selectedOptimizations`.
-3. Pass the baseline entry to `resolveOptimizedEntry()`. In a stateful Web SDK integration, the
- method uses current SDK state when you omit the second argument.
+3. Call `fetchOptimizedEntry(entryId)` to fetch the baseline entry and resolve the selected variant.
4. Render the returned `entry`. If no matching optimization exists, the SDK returns the baseline
entry.
5. Store the baseline entry ID separately from the resolved entry ID so later rerenders do not
@@ -366,14 +372,11 @@ baseline entry and Experience API selections exist.
```ts
async function renderEntry(entryId: string, element: HTMLElement): Promise {
- const baselineEntry = await contentfulClient.getEntry(entryId, {
- include: 10,
- locale: APP_LOCALE,
+ const resolved = await optimization.fetchOptimizedEntry(entryId, {
+ query: { locale: APP_LOCALE },
})
- // Omitted selections use current SDK state from the most recent accepted page or identify call.
- const resolved = optimization.resolveOptimizedEntry(baselineEntry)
- const { entry, optimizationContextId, selectedOptimization } = resolved
+ const { baselineEntry, entry, optimizationContextId, selectedOptimization } = resolved
element.textContent = String(entry.fields.headline ?? '')
@@ -399,9 +402,25 @@ async function renderEntry(entryId: string, element: HTMLElement): Promise
}
```
+Manual baseline fetching plus `resolveOptimizedEntry()` remains supported when the app needs custom
+delivery behavior or already has the baseline entry:
+
+**Adapt this to your use case:**
+
+```ts
+const baselineEntry = await contentfulClient.getEntry(entryId, {
+ include: 10,
+ locale: APP_LOCALE,
+})
+
+// Omitted selections use current SDK state from the most recent accepted page or identify call.
+const { entry } = optimization.resolveOptimizedEntry(baselineEntry)
+```
+
Entry resolution expects standard single-locale CDA fields such as `fields.nt_experiences` and
-`fields.nt_variants`. All-locale CDA responses put field values under locale keys and cause
-resolution to fall back to the baseline entry.
+`fields.nt_variants`. Do not configure SDK-managed fetches or manual fetches with `contentful.js`
+`withAllLocales` or raw CDA `locale=*` responses. All-locale CDA responses put field values under
+locale keys and cause resolution to fall back to the baseline entry.
For deeper mechanics and fallback behavior, see
[Entry personalization and variant resolution](../concepts/entry-personalization-and-variant-resolution.md).
@@ -645,7 +664,9 @@ resolution without a framework adapter.
3. Use one root for entries that share one SDK instance. The root can create the SDK from attributes
and assigned properties, or it can reuse an existing `window.contentfulOptimization` instance.
4. Assign structured values such as `defaults`, `api`, `trackEntryInteraction`, `sdk`, and
- `baselineEntry` as DOM properties, not string attributes.
+ `baselineEntry` as DOM properties, not string attributes. Use `entry-id` as SDK-managed fetch
+ input when the active SDK has `contentful.client`; assign `baselineEntry` only when app code
+ fetches the entry itself.
5. Listen for `ctfl-entry-loading`, `ctfl-entry-resolved`, and `ctfl-entry-error` to render
application-owned UI.
@@ -658,28 +679,21 @@ import {
type ContentfulOptimizedEntryEventDetail,
defineContentfulOptimizationElements,
} from '@contentful/optimization-web/web-components'
+import { optimization } from './optimization'
defineContentfulOptimizationElements()
const root = document.querySelector('ctfl-optimization-root')
const entry = document.querySelector(
- 'ctfl-optimized-entry[data-entry-id]',
+ 'ctfl-optimized-entry[entry-id]',
)
if (root) {
- // Structured SDK options must be assigned as properties, not string attributes.
- root.defaults = { consent: true }
- root.trackEntryInteraction = { hovers: false }
+ // Reuse the app-owned SDK configured with contentful: { client }.
+ root.sdk = optimization
}
-if (entry?.dataset.entryId) {
- const baselineEntry = await contentfulClient.getEntry(entry.dataset.entryId, {
- include: 10,
- locale: APP_LOCALE,
- })
-
- // The SDK resolves after app code supplies the structured baseline entry object.
- entry.baselineEntry = baselineEntry
+if (entry) {
entry.addEventListener('ctfl-entry-resolved', (event) => {
const { detail } = event as CustomEvent
@@ -691,12 +705,13 @@ if (entry?.dataset.entryId) {
**Follow this pattern:**
```html
-
-
+
+
```
-The `data-entry-id` attribute above is app-owned lookup metadata, not SDK fetch configuration.
+The `entry-id` attribute is SDK-managed fetch input when the active SDK has `contentful.client`.
+Assign the `baselineEntry` property only when app code fetches the entry itself.
`@contentful/optimization-web/web-components` is side-effect-free. Custom elements are registered
only when `defineContentfulOptimizationElements()` runs. If the root owns the SDK instance,
diff --git a/documentation/product/framework-and-meta-framework-reference-implementation-requirements.md b/documentation/product/framework-and-meta-framework-reference-implementation-requirements.md
index 542e4a02c..c14b25731 100644
--- a/documentation/product/framework-and-meta-framework-reference-implementation-requirements.md
+++ b/documentation/product/framework-and-meta-framework-reference-implementation-requirements.md
@@ -13,13 +13,13 @@ title: Framework and meta-framework reference implementation requirements
## Summary
-Reference implementations are executable examples and validation harnesses for supported
-Optimization SDK integration patterns. They show SDK maintainers and application engineers how
-public SDK APIs fit into realistic framework and meta-framework applications.
+Reference implementations are executable validation artifacts for supported Optimization SDK
+integration patterns. They show SDK maintainers and application engineers how public SDK APIs fit
+into realistic framework and meta-framework applications.
-Each reference implementation must demonstrate the intended application workflow, exercise the
-relevant SDK surface against shared mocks and fixtures, and provide automated E2E coverage for the
-practical happy paths exposed by the demonstrated SDKs.
+Each reference implementation must validate the intended application workflow, exercise the relevant
+SDK surface against shared mocks and fixtures, and provide automated E2E coverage for the practical
+happy paths exposed by the demonstrated SDKs.
The product goal is to keep framework SDKs and meta-framework patterns usable, documented, and
validated across personalization, tracking, consent, locale handling, preview, offline behavior,
@@ -90,10 +90,10 @@ throwaway demos.
## Selection requirements
- **REFREQ-1 SDK coverage** - Each public framework adapter SDK must have at least one reference
- implementation that demonstrates its primary integration path.
+ implementation that validates its primary integration path.
- **REFREQ-2 Runtime coverage** - Each public runtime SDK must have at least one reference
- implementation that demonstrates initialization, consent, personalization, tracking, locale
- handling, diagnostics, and preview where the runtime supports those behaviors.
+ implementation that validates initialization, consent, personalization, tracking, locale handling,
+ diagnostics, and preview where the runtime supports those behaviors.
- **REFREQ-3 Meta-framework coverage** - Each supported meta-framework pattern must have a reference
implementation when the pattern involves distinct server/browser, routing, rendering, caching, or
hydration decisions.
@@ -110,13 +110,13 @@ throwaway demos.
- **REFREQ-7 Customer-style structure** - The app must use normal framework application structure:
routes, screens, providers, modules, middleware, controllers, components, or views as appropriate.
- **REFREQ-8 Minimal domain surface** - The app must include enough content, navigation, controls,
- and state display to validate SDK behavior without becoming a broad sample product.
+ and state display to validate SDK behavior without becoming a broad product shell.
- **REFREQ-9 App-owned content fetching** - The app must fetch Contentful entries in application
code and pass single-locale CDA entries to SDK resolution helpers.
- **REFREQ-10 App-owned consent controls** - The app must include explicit consent controls when the
- demonstrated flow depends on user consent.
+ validated flow depends on user consent.
- **REFREQ-11 App-owned identity controls** - The app must include identify and reset controls when
- profile transitions are part of the SDK behavior being demonstrated.
+ profile transitions are part of the SDK behavior being validated.
- **REFREQ-12 Real routing or navigation** - The app must use the framework's routing or navigation
system when testing page, screen, request, middleware, or navigation tracking.
- **REFREQ-13 Stable test identifiers** - The app must expose stable automation identifiers for
@@ -294,7 +294,7 @@ pattern.
## README and runbook requirements
- **REFREQ-71 README role** - Each reference implementation README must explain what the app
- demonstrates, which SDK packages it validates, and which application pattern it represents.
+ validates, which SDK packages it exercises, and which application pattern it represents.
- **REFREQ-72 Setup** - The README must provide setup commands from the monorepo root and document
environment variables using `.env.example`.
- **REFREQ-73 Running locally** - The README must explain how to start the app and required mock
@@ -320,8 +320,7 @@ Use this checklist when adding or evaluating a framework or meta-framework refer
- [ ] The implementation has a clear SDK or pattern owner and belongs in the correct
`implementations/` subtree.
- [ ] The app uses public SDK APIs and does not import package internals or generated outputs.
-- [ ] The app demonstrates a realistic framework setup path rather than a synthetic test harness
- alone.
+- [ ] The app validates a realistic framework setup path rather than a synthetic test harness alone.
- [ ] App-owned Contentful fetching uses the resolved Contentful locale and single-locale CDA entry
payloads.
- [ ] Consent, identify, reset, and profile-continuity flows are observable and testable when the
diff --git a/documentation/product/optimization-sdk-suite-fprd.md b/documentation/product/optimization-sdk-suite-fprd.md
index 1208c592d..87a5a9b96 100644
--- a/documentation/product/optimization-sdk-suite-fprd.md
+++ b/documentation/product/optimization-sdk-suite-fprd.md
@@ -22,8 +22,8 @@ The suite is layered:
- Framework adapter SDKs provide framework-native integration surfaces on top of runtime SDKs.
- Meta-framework reference patterns document supported SDK compositions for application frameworks
that do not require a dedicated SDK.
-- Reference implementations provide executable examples and validation harnesses for supported
- integration patterns.
+- Reference implementations provide executable validation artifacts for supported integration
+ patterns.
- Supporting packages provide low-level API transport, schemas, preview tooling, and native bridge
infrastructure.
@@ -243,22 +243,22 @@ Reference implementations are part of the SDK Suite product surface. They show h
fit into realistic applications and provide executable validation for supported integration
patterns.
-- **REF-1 Public-surface examples** - Each reference implementation must use the public SDK surface
- it demonstrates. Reusable SDK behavior belongs in packages, not reference apps.
+- **REF-1 Public SDK surface** - Each reference implementation must use the public SDK surface it
+ validates. Reusable SDK behavior belongs in packages, not reference apps.
- **REF-2 SDK coverage** - Each published SDK must have at least one reference implementation that
- demonstrates its primary integration path.
+ validates its primary integration path.
- **REF-3 Runtime behavior coverage** - Runtime SDK reference implementations must demonstrate
initialization, consent, personalization, tracking, locale handling, local mock API usage, and
preview where the runtime supports it.
- **REF-4 Framework adapter coverage** - Each framework adapter SDK must have a customer-style
- reference implementation that demonstrates framework-native setup, routing or request tracking,
+ reference implementation that validates framework-native setup, routing or request tracking,
optimized rendering, live updates, preview where supported, and analytics handoff.
- **REF-5 Meta-framework pattern coverage** - Meta-frameworks such as Nuxt.js and SvelteKit can be
supported through reference implementations instead of dedicated SDKs when SDK composition covers
the required behavior. When a dedicated adapter exists, such as Next.js, its reference
implementations must validate the adapter and the relevant rendering modalities.
- **REF-6 Modality coverage** - CSR, SSR, prerendering, and hybrid web application patterns must
- have separate reference implementations when combining them would make the example unclear or
+ have separate reference implementations when combining them would make the reference unclear or
technically impractical. This applies whether those modalities are built into a framework or
provided by a meta-framework.
- **REF-7 Native parity coverage** - Native iOS and Android reference implementations must cover
@@ -272,7 +272,7 @@ patterns.
- **REF-10 E2E validation** - Reference implementations must define the appropriate E2E runner for
the platform, such as Playwright, Detox, XCUITest, or Maestro.
- **REF-11 Documentation role** - Reference implementation README files must explain what the app
- demonstrates, how to run it, how to validate it, and which SDK or package docs it supports.
+ validates, how to run it, how to validate it, and which SDK or package docs it supports.
- **REF-12 Known gaps** - Reference implementations must document product or handoff gaps when they
expose a missing SDK capability, such as server-to-client initial optimization data seeding.
@@ -409,8 +409,8 @@ The suite must support these application and validation workflows:
- Framework adapter SDKs must reuse runtime SDKs and Core behavior rather than diverging into
independent implementations.
- Native SDKs must keep bridge behavior aligned with shared Core semantics.
-- Reference implementations must stay small and example-oriented. They must not become reusable
- application frameworks or hidden SDK layers.
+- Reference implementations must stay focused, consumer-oriented, and coverage-preserving. They must
+ not become reusable application frameworks or hidden SDK layers.
- Shared mocks, fixtures, and scenario contracts are internal validation dependencies for reference
implementations.
- Documentation that describes public SDK status must align with this fPRD. If package docs describe
diff --git a/implementations/AGENTS.md b/implementations/AGENTS.md
index ac67af45d..a41af3eb3 100644
--- a/implementations/AGENTS.md
+++ b/implementations/AGENTS.md
@@ -4,12 +4,17 @@ Applies to reference implementations and shared implementation contracts under `
## Boundaries
-- Reference implementations are maintained SDK integration references, E2E targets, and concrete
- consumer reference material. They are not demos or package-local `dev/` harnesses; do not dismiss
- failures, stale flows, or docs gaps as demo-only.
+- Reference implementations are first-class product artifacts: maintained SDK integration contracts,
+ E2E targets, customer-facing evidence, and concrete consumer reference material. They are not
+ optional demos or package-local `dev/` harnesses; do not dismiss failures, stale flows, incomplete
+ coverage, or docs gaps as demo-only.
- Reusable SDK behavior belongs in `packages/`; reference implementations should consume the public
SDK surface the way customers do.
-- Keep apps small, consumer-oriented, and aligned with the public SDK surface they exercise.
+- Keep apps focused, consumer-oriented, coverage-preserving, and aligned with the public SDK surface
+ they exercise.
+- Do not remove, reduce, or bypass implementation behavior because it appears app-local. First
+ verify whether it documents a supported integration path, backs E2E coverage, or exposes an SDK
+ gap.
- CDA entry fetches used for SDK entry resolution must stay single-locale. Do not use
`withAllLocales` or `locale=*`; choose the application Contentful locale in the implementation and
pass it explicitly to CDA requests.
@@ -22,7 +27,8 @@ Applies to reference implementations and shared implementation contracts under `
- Follow root Markdown rules and [`../STYLE_GUIDE.md`](../STYLE_GUIDE.md).
- Use the repo-standard header, implementation-specific ``, Readme/Guides/Reference/Contributing
- navigation, pre-release warning, and an introduction naming the SDK packages they integrate.
+ navigation, pre-release warning, and an introduction naming the SDK packages they integrate and
+ the customer-style integration path they validate.
- Use this default top-level order: header/navigation/warning, introduction naming the integrated
SDK package or native status, `## What this covers`, optional near-top architecture notes,
`## CDA locale handling`, `## Prerequisites`, `## Setup`, `## Running locally`,
diff --git a/implementations/nextjs-sdk_hybrid/README.md b/implementations/nextjs-sdk_hybrid/README.md
index 8dde2705a..5d41ecdd9 100644
--- a/implementations/nextjs-sdk_hybrid/README.md
+++ b/implementations/nextjs-sdk_hybrid/README.md
@@ -89,6 +89,12 @@ See
and
[Entry personalization and variant resolution](../../documentation/concepts/entry-personalization-and-variant-resolution.md#single-locale-cda-entry-contract).
+This reference demonstrates the supported manual SSR path: Server Components fetch single-locale
+Contentful entries, then pass them to SDK entry resolution. For Next.js integrations with an
+application-owned `contentful.js` client, we recommend configuring `createNextjsOptimization()` with
+`contentful: { client: contentfulClient }` and using `requestOptimization.fetchOptimizedEntry()`
+unless the route must own Contentful fetching, caching, or response shaping.
+
## Route strategy
Use Server Components for routes that fetch Contentful entries and request Optimization state, and
diff --git a/implementations/nextjs-sdk_ssr/README.md b/implementations/nextjs-sdk_ssr/README.md
index caebe6f96..392ad7fc8 100644
--- a/implementations/nextjs-sdk_ssr/README.md
+++ b/implementations/nextjs-sdk_ssr/README.md
@@ -92,6 +92,12 @@ See
and
[Entry personalization and variant resolution](../../documentation/concepts/entry-personalization-and-variant-resolution.md#single-locale-cda-entry-contract).
+This reference demonstrates the supported manual SSR path: Server Components fetch single-locale
+Contentful entries, then pass them to SDK entry resolution. For Next.js integrations with an
+application-owned `contentful.js` client, we recommend configuring `createNextjsOptimization()` with
+`contentful: { client: contentfulClient }` and using `requestOptimization.fetchOptimizedEntry()`
+unless the route must own Contentful fetching, caching, or response shaping.
+
## Prerequisites
- Node.js >= 20.19.0 (24.15.0 recommended to match `.nvmrc`)
diff --git a/implementations/node-sdk+web-sdk/README.md b/implementations/node-sdk+web-sdk/README.md
index 51b9458fe..70a3672a0 100644
--- a/implementations/node-sdk+web-sdk/README.md
+++ b/implementations/node-sdk+web-sdk/README.md
@@ -61,6 +61,13 @@ for the broader locale model and
[Entry personalization and variant resolution](../../documentation/concepts/entry-personalization-and-variant-resolution.md#single-locale-cda-entry-contract)
for the entry contract.
+This reference uses the supported manual path: application code fetches single-locale Contentful
+entries and passes them to SDK entry resolution. For JavaScript integrations with an
+application-owned `contentful.js` client, we recommend configuring the SDK with
+`contentful: { client: contentfulClient }` and using managed fetching (`fetchOptimizedEntry()`,
+`entryId`, and optional `entryQuery` where supported). Keep the manual `baselineEntry` /
+`resolveOptimizedEntry()` path when the application must own fetching, caching, or response shaping.
+
## Prerequisites
- Node.js >= 20.19.0 (24.15.0 recommended to match `.nvmrc`)
diff --git a/implementations/node-sdk/AGENTS.md b/implementations/node-sdk/AGENTS.md
index e236661f0..b34bcf9fe 100644
--- a/implementations/node-sdk/AGENTS.md
+++ b/implementations/node-sdk/AGENTS.md
@@ -4,7 +4,7 @@ Node SSR reference implementation for `@contentful/optimization-node`.
## Rules
-- Keep this app minimal and documentation-oriented; reusable Node SDK behavior belongs in
+- Keep this app focused and reference-oriented; reusable Node SDK behavior belongs in
`packages/node/node-sdk`.
- Local mock defaults come from `.env.example`.
- `serve` uses PM2-managed processes; use `serve:stop` when done.
diff --git a/implementations/node-sdk/README.md b/implementations/node-sdk/README.md
index 7adb017b6..7e074ebb1 100644
--- a/implementations/node-sdk/README.md
+++ b/implementations/node-sdk/README.md
@@ -52,6 +52,13 @@ for the broader locale model and
[Entry personalization and variant resolution](../../documentation/concepts/entry-personalization-and-variant-resolution.md#single-locale-cda-entry-contract)
for the entry contract.
+This reference uses the supported manual path: application code fetches single-locale Contentful
+entries and passes them to SDK entry resolution. For JavaScript integrations with an
+application-owned `contentful.js` client, we recommend configuring the SDK with
+`contentful: { client: contentfulClient }` and using managed fetching (`fetchOptimizedEntry()`,
+`entryId`, and optional `entryQuery` where supported). Keep the manual `baselineEntry` /
+`resolveOptimizedEntry()` path when the application must own fetching, caching, or response shaping.
+
## Prerequisites
- Node.js >= 20.19.0 (24.15.0 recommended to match `.nvmrc`)
diff --git a/implementations/react-web-sdk/README.md b/implementations/react-web-sdk/README.md
index faed142da..b7a216cb5 100644
--- a/implementations/react-web-sdk/README.md
+++ b/implementations/react-web-sdk/README.md
@@ -64,6 +64,13 @@ for the broader locale model and
[Entry personalization and variant resolution](../../documentation/concepts/entry-personalization-and-variant-resolution.md#single-locale-cda-entry-contract)
for the entry contract.
+This reference uses the supported manual path: application code fetches single-locale Contentful
+entries and passes them to SDK entry resolution. For JavaScript integrations with an
+application-owned `contentful.js` client, we recommend configuring the SDK with
+`contentful: { client: contentfulClient }` and using managed fetching (`fetchOptimizedEntry()`,
+`entryId`, and optional `entryQuery` where supported). Keep the manual `baselineEntry` /
+`resolveOptimizedEntry()` path when the application must own fetching, caching, or response shaping.
+
## Prerequisites
- Node.js >= 20.19.0 (24.15.0 recommended to match `.nvmrc`)
@@ -230,8 +237,8 @@ Implementation-specific touchpoints:
| `src/components/AnalyticsEventDisplay.tsx` | Displays event stream output from `sdk.states.eventStream` |
| Manual `selectedOptimizations` lock logic | `` |
-**What stays the same:** `contentfulClient.ts`, locale config, type definitions, `RichTextRenderer`,
-E2E test files, page/section component structure.
+**What stays the same in this reference path:** `contentfulClient.ts`, locale config, type
+definitions, `RichTextRenderer`, E2E test files, page/section component structure.
**Key architectural difference:** `App.tsx` acts as a persistent layout (contains
`AnalyticsEventDisplay` that stays mounted across route changes). Pages are route children that
diff --git a/implementations/web-sdk/AGENTS.md b/implementations/web-sdk/AGENTS.md
index 96ff5be66..74022093f 100644
--- a/implementations/web-sdk/AGENTS.md
+++ b/implementations/web-sdk/AGENTS.md
@@ -4,8 +4,8 @@ Vanilla JS reference implementation for `@contentful/optimization-web`.
## Rules
-- Keep this app minimal and example-oriented; reusable runtime logic belongs in
- `packages/web/web-sdk`.
+- Keep this app focused, consumer-oriented, and coverage-preserving; reusable runtime logic belongs
+ in `packages/web/web-sdk`.
- `build` copies Web SDK, preview-panel, and `lib/e2e-web/src/theme.css` assets into `public/dist`.
- `server.ts` is a lightweight Node.js HTTP server; it reads `.env` (or `.env.example`), injects env
vars as `window.ENVIRONMENT` into the HTML, and serves `public/` with an SPA fallback. No Docker
diff --git a/implementations/web-sdk/README.md b/implementations/web-sdk/README.md
index ea8dcd7a8..1f006a5c1 100644
--- a/implementations/web-sdk/README.md
+++ b/implementations/web-sdk/README.md
@@ -41,6 +41,13 @@ for the broader locale model and
[Entry personalization and variant resolution](../../documentation/concepts/entry-personalization-and-variant-resolution.md#single-locale-cda-entry-contract)
for the entry contract.
+This reference uses the supported manual path: application code fetches single-locale Contentful
+entries and passes them to SDK entry resolution. For JavaScript integrations with an
+application-owned `contentful.js` client, we recommend configuring the SDK with
+`contentful: { client: contentfulClient }` and using managed fetching (`fetchOptimizedEntry()`,
+`entryId`, and optional `entryQuery` where supported). Keep the manual `baselineEntry` /
+`resolveOptimizedEntry()` path when the application must own fetching, caching, or response shaping.
+
## Prerequisites
- Node.js >= 20.19.0 (24.15.0 recommended to match `.nvmrc`)
diff --git a/implementations/web-sdk_angular/README.md b/implementations/web-sdk_angular/README.md
index f154fbc89..28ae66520 100644
--- a/implementations/web-sdk_angular/README.md
+++ b/implementations/web-sdk_angular/README.md
@@ -54,6 +54,13 @@ for the broader locale model and
[Entry personalization and variant resolution](../../documentation/concepts/entry-personalization-and-variant-resolution.md#single-locale-cda-entry-contract)
for the entry contract.
+This reference uses the supported manual path: application code fetches single-locale Contentful
+entries and passes them to SDK entry resolution. For JavaScript integrations with an
+application-owned `contentful.js` client, we recommend configuring the SDK with
+`contentful: { client: contentfulClient }` and using managed fetching (`fetchOptimizedEntry()`,
+`entryId`, and optional `entryQuery` where supported). Keep the manual `baselineEntry` /
+`resolveOptimizedEntry()` path when the application must own fetching, caching, or response shaping.
+
## Prerequisites
- Node.js >= 20.19.0 (24.15.0 recommended to match `.nvmrc`)
diff --git a/implementations/web-sdk_react/AGENTS.md b/implementations/web-sdk_react/AGENTS.md
index 153eeccf0..fd0e1605d 100644
--- a/implementations/web-sdk_react/AGENTS.md
+++ b/implementations/web-sdk_react/AGENTS.md
@@ -1,6 +1,6 @@
# AGENTS.md
-React reference implementation demonstrating direct `@contentful/optimization-web` usage in a React
+React reference implementation for direct `@contentful/optimization-web` usage in a React
application.
## Rules
diff --git a/implementations/web-sdk_react/README.md b/implementations/web-sdk_react/README.md
index e74da1f60..140d1d814 100644
--- a/implementations/web-sdk_react/README.md
+++ b/implementations/web-sdk_react/README.md
@@ -65,6 +65,13 @@ for the broader locale model and
[Entry personalization and variant resolution](../../documentation/concepts/entry-personalization-and-variant-resolution.md#single-locale-cda-entry-contract)
for the entry contract.
+This reference uses the supported manual path: application code fetches single-locale Contentful
+entries and passes them to SDK entry resolution. For JavaScript integrations with an
+application-owned `contentful.js` client, we recommend configuring the SDK with
+`contentful: { client: contentfulClient }` and using managed fetching (`fetchOptimizedEntry()`,
+`entryId`, and optional `entryQuery` where supported). Keep the manual `baselineEntry` /
+`resolveOptimizedEntry()` path when the application must own fetching, caching, or response shaping.
+
## Prerequisites
- Node.js >= 20.19.0 (24.15.0 recommended to match `.nvmrc`)
diff --git a/packages/AGENTS.md b/packages/AGENTS.md
index 6879699fd..d5a8625c8 100644
--- a/packages/AGENTS.md
+++ b/packages/AGENTS.md
@@ -4,8 +4,8 @@ Applies to all workspace packages under `packages/`.
## Boundaries
-- Published SDK behavior belongs in packages; reference implementations consume it through public
- APIs as maintained E2E targets and consumer references.
+- Published SDK behavior belongs in packages; reference implementations are first-class downstream
+ consumers that exercise public APIs as maintained E2E targets and consumer references.
- Shared cross-platform behavior usually belongs in `packages/universal/core-sdk` unless it is
clearly platform-specific.
- Keep package-local `dev/` harnesses aligned with the SDK behavior they exercise.
@@ -42,8 +42,9 @@ For pnpm-managed packages with matching scripts, use `pnpm --filter
+
{(resolvedEntry) => }
)
}
```
-Fetch Contentful entries in your app layer. For optimized entries, request linked entries deeply
-enough for the baseline and variants, commonly with `include: 10`.
+Configure the SDK with `contentful: { client }` on `OptimizationRoot`, `OptimizationProvider`, or
+`ContentfulOptimization.create(...)`. SDK-managed fetching merges `contentful.defaultQuery`,
+per-entry `entryQuery`, the SDK `locale` fallback, and `include: 10`. Manual baseline entries remain
+supported and unchanged:
-Use one CDA locale for entries passed to `OptimizedEntry` or `useEntryResolver()`. For localized
-apps, derive the application locale from your navigation, i18n, or app configuration layer and pass
-it directly to Contentful CDA requests. Do not pass all-locale CDA responses from `withAllLocales`
-or `locale=*`; these APIs expect direct single-locale field values. See
+```tsx
+
+ {(resolvedEntry) => }
+
+```
+
+Use one CDA locale for entries fetched through `entryId`, passed to `OptimizedEntry`, or resolved
+with `useEntryResolver()` or `useOptimizedEntry()`. For localized apps, derive the application
+locale from your navigation, i18n, or app configuration layer and pass it to `entryQuery`,
+`contentful.defaultQuery`, or manual Contentful CDA requests. Do not pass all-locale CDA responses
+from `withAllLocales` or `locale=*`; these APIs expect direct single-locale field values. See
[Entry personalization and variant resolution](https://contentful.github.io/optimization/documents/Documentation.Concepts.Entry_personalization_and_variant_resolution.html#single-locale-cda-entry-contract)
for the entry contract and
[Locale handling in the Optimization SDK Suite](https://contentful.github.io/optimization/documents/Documentation.Concepts.Locale_handling_in_the_Optimization_SDK_Suite.html)
for the broader locale model.
+Use `useOptimizedEntry()` when a component needs the same managed `entryId` or manual
+`baselineEntry` source model without the `OptimizedEntry` wrapper:
+
+```tsx
+import { useOptimizedEntry } from '@contentful/optimization-react-native'
+
+function HeroData() {
+ const { entry, isLoading, error } = useOptimizedEntry({ entryId: 'hero-entry-id' })
+
+ if (isLoading || error || !entry) return null
+
+ return
+}
+```
+
+Use `onEntryResolved`, the render prop metadata, or the hook's `metadata` and `isResolved` fields
+when application code needs the baseline ID, resolved entry ID, or optimization context after
+tracking is ready.
+
Use `useEntryResolver()` when a component needs manual entry resolution without the `OptimizedEntry`
wrapper:
diff --git a/packages/react-native-sdk/src/components/OptimizedEntry.test.tsx b/packages/react-native-sdk/src/components/OptimizedEntry.test.tsx
index 3f63c4a84..12f3dc13b 100644
--- a/packages/react-native-sdk/src/components/OptimizedEntry.test.tsx
+++ b/packages/react-native-sdk/src/components/OptimizedEntry.test.tsx
@@ -15,6 +15,16 @@ const selectedOptimizations = {
subscribe: rs.fn(() => ({ unsubscribe: rs.fn() })),
}
const resolveOptimizedEntry = rs.fn((entry: Entry): ResolvedData => ({ entry }))
+const fetchContentfulEntry = rs.fn(
+ async (entryId: string) => await Promise.resolve(createEntry(entryId)),
+)
+const optimization = {
+ fetchContentfulEntry,
+ resolveOptimizedEntry,
+ states: {
+ selectedOptimizations,
+ },
+}
const useViewportTracking = rs.fn((_options: Record) => ({
isVisible: false,
onLayout: rs.fn(),
@@ -29,12 +39,7 @@ rs.mock('react-native', () => ({
}))
rs.mock('../context/OptimizationContext', () => ({
- useOptimization: () => ({
- resolveOptimizedEntry,
- states: {
- selectedOptimizations,
- },
- }),
+ useOptimization: () => optimization,
}))
rs.mock('../hooks/useViewportTracking', () => ({
@@ -68,6 +73,21 @@ function createEntry(id: string): Entry {
}
}
+function createDeferred(): {
+ readonly promise: Promise
+ readonly reject: (reason?: unknown) => void
+ readonly resolve: (value: T) => void
+} {
+ let resolveDeferred: (value: T) => void = () => undefined
+ let rejectDeferred: (reason?: unknown) => void = () => undefined
+ const promise = new Promise((resolve, reject) => {
+ resolveDeferred = resolve
+ rejectDeferred = reject
+ })
+
+ return { promise, reject: rejectDeferred, resolve: resolveDeferred }
+}
+
function getCallOptions(
mock: typeof useViewportTracking | typeof useTapTracking,
): Record {
@@ -97,6 +117,14 @@ describe('OptimizedEntry', () => {
void beforeEach(() => {
rs.clearAllMocks()
selectedOptimizations.current = undefined
+ fetchContentfulEntry.mockImplementation(
+ async (entryId: string) => await Promise.resolve(createEntry(entryId)),
+ )
+ resolveOptimizedEntry.mockImplementation(
+ (entry: Entry): ResolvedData => ({
+ entry,
+ }),
+ )
})
void afterEach(() => {
@@ -211,4 +239,94 @@ describe('OptimizedEntry', () => {
expect(getCallOptions(useViewportTracking).optimizationContextId).toBe('ctx-1')
expect(getCallOptions(useTapTracking).optimizationContextId).toBe('ctx-1')
})
+
+ it('passes resolved metadata to render props and onEntryResolved', async () => {
+ const { OptimizedEntry } = await import('./OptimizedEntry')
+ const testRenderer = await loadTestRenderer()
+ const baselineEntry = createEntry('baseline-entry')
+ const renderedMetadata: string[] = []
+ const onEntryResolved = rs.fn()
+
+ act(() => {
+ renderer = testRenderer.create(
+
+ {(resolved, metadata) => {
+ renderedMetadata.push(
+ `${metadata.baselineEntryId}:${metadata.entryId}:${metadata.optimizationContextId}`,
+ )
+ return resolved.sys.id
+ }}
+ ,
+ )
+ })
+
+ expect(renderedMetadata).toContain('baseline-entry:baseline-entry:undefined')
+ expect(onEntryResolved).toHaveBeenCalledWith(
+ expect.objectContaining({
+ baselineEntry,
+ baselineEntryId: 'baseline-entry',
+ entry: baselineEntry,
+ entryId: 'baseline-entry',
+ }),
+ )
+ })
+
+ it('renders loadingFallback and skips tracking while managed entryId is loading', async () => {
+ const { OptimizedEntry } = await import('./OptimizedEntry')
+ const testRenderer = await loadTestRenderer()
+ const deferred = createDeferred()
+ fetchContentfulEntry.mockImplementation(async () => await deferred.promise)
+
+ act(() => {
+ renderer = testRenderer.create(
+
+ {(resolvedEntry) => resolvedEntry.sys.id}
+ ,
+ )
+ })
+
+ expect(fetchContentfulEntry).toHaveBeenCalledWith('baseline-entry', { locale: 'de-DE' })
+ expect(useViewportTracking).not.toHaveBeenCalled()
+ expect(useTapTracking).not.toHaveBeenCalled()
+
+ const baselineEntry = createEntry('baseline-entry')
+ await act(async () => {
+ deferred.resolve(baselineEntry)
+ await deferred.promise
+ })
+
+ expect(getCallOptions(useViewportTracking).entry).toBe(baselineEntry)
+ expect(getCallOptions(useTapTracking).entry).toBe(baselineEntry)
+ })
+
+ it('renders managed entryId fetch errors and reports each error once', async () => {
+ const { OptimizedEntry } = await import('./OptimizedEntry')
+ const testRenderer = await loadTestRenderer()
+ const error = new Error('CDA failed')
+ const onEntryError = rs.fn()
+ fetchContentfulEntry.mockImplementation(async () => await Promise.reject(error))
+
+ await act(async () => {
+ renderer = testRenderer.create(
+ `error: ${entryError.message}`}
+ onEntryError={onEntryError}
+ >
+ {(resolvedEntry) => resolvedEntry.sys.id}
+ ,
+ )
+ await Promise.resolve()
+ await Promise.resolve()
+ })
+
+ expect(onEntryError).toHaveBeenCalledTimes(1)
+ expect(onEntryError).toHaveBeenCalledWith(error)
+ expect(useViewportTracking).not.toHaveBeenCalled()
+ expect(useTapTracking).not.toHaveBeenCalled()
+ })
})
diff --git a/packages/react-native-sdk/src/components/OptimizedEntry.tsx b/packages/react-native-sdk/src/components/OptimizedEntry.tsx
index 6a4451030..7828f5d39 100644
--- a/packages/react-native-sdk/src/components/OptimizedEntry.tsx
+++ b/packages/react-native-sdk/src/components/OptimizedEntry.tsx
@@ -1,41 +1,35 @@
-import type { ResolvedData } from '@contentful/optimization-core'
-import type { SelectedOptimizationArray } from '@contentful/optimization-core/api-schemas'
-import { isResolvedOptimizedEntry } from '@contentful/optimization-core/api-schemas'
+import type {
+ ContentfulEntryQuery,
+ OptimizedEntryMetadata,
+ ResolvedData,
+} from '@contentful/optimization-core'
import type { Entry, EntrySkeletonType } from 'contentful'
-import React, { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
+import React, { type ReactNode } from 'react'
import { View, type StyleProp, type ViewStyle } from 'react-native'
import { useInteractionTracking } from '../context/InteractionTrackingContext'
-import { useLiveUpdates } from '../context/LiveUpdatesContext'
-import { useOptimization } from '../context/OptimizationContext'
+import { useOptimizedEntry, type UseOptimizedEntryParams } from '../hooks/useOptimizedEntry'
import { useTapTracking } from '../hooks/useTapTracking'
import { useViewportTracking } from '../hooks/useViewportTracking'
+export type OptimizedEntryLoadingFallback = ReactNode | (() => ReactNode)
+export type OptimizedEntryErrorFallback = ReactNode | ((error: Error) => ReactNode)
+export type OptimizedEntryRenderProp = (
+ resolvedEntry: Entry,
+ metadata: OptimizedEntryMetadata,
+) => ReactNode
+export type OptimizedEntryChildren = ReactNode | OptimizedEntryRenderProp
+
/**
- * Props for the {@link OptimizedEntry} component.
+ * Shared props for the {@link OptimizedEntry} component.
*
* @public
*/
-export interface OptimizedEntryProps {
- /**
- * The baseline Contentful entry to optimize and track.
- * For optimized entries (those with `nt_experiences`), the component
- * automatically resolves variants. For non-optimized entries, the
- * entry is passed through unchanged.
- *
- * @example
- * ```typescript
- * const entry = await contentful.getEntry('entry-id', {
- * include: 10,
- * })
- * ```
- */
- baselineEntry: Entry
-
+interface OptimizedEntrySharedProps {
/**
* Content to render. Accepts either a render prop or static children.
*
- * - **Render prop** `(resolvedEntry: Entry) => ReactNode`: receives the
- * resolved entry (variant or baseline) and returns content to render.
+ * - **Render prop** `(resolvedEntry: Entry, metadata: OptimizedEntryMetadata) => ReactNode`:
+ * receives the resolved entry plus baseline and optimization metadata.
* Use this when you need the resolved entry data.
* - **Static children** `ReactNode`: rendered as-is without entry data.
* Use this when you only need tracking, not variant resolution.
@@ -59,7 +53,27 @@ export interface OptimizedEntryProps {
*
* ```
*/
- children: ReactNode | ((resolvedEntry: Entry) => ReactNode)
+ children: OptimizedEntryChildren
+
+ /**
+ * Optional fallback rendered while SDK-managed entry fetching is pending.
+ */
+ loadingFallback?: OptimizedEntryLoadingFallback
+
+ /**
+ * Optional fallback rendered when SDK-managed entry fetching fails.
+ */
+ errorFallback?: OptimizedEntryErrorFallback
+
+ /**
+ * Callback invoked once for each SDK-managed entry fetching error.
+ */
+ onEntryError?: (error: Error) => void
+
+ /**
+ * Callback invoked when a resolved entry is rendered with tracking ready.
+ */
+ onEntryResolved?: (metadata: OptimizedEntryMetadata) => void
/**
* Minimum time (in milliseconds) the component must be visible
@@ -140,6 +154,32 @@ export interface OptimizedEntryProps {
onTap?: (resolvedEntry: Entry) => void
}
+type OptimizedEntrySourceProps =
+ | {
+ /**
+ * The baseline Contentful entry to optimize and track.
+ * For optimized entries, the component resolves variants. For non-optimized entries,
+ * the entry is passed through unchanged.
+ */
+ baselineEntry: Entry
+ entryId?: never
+ entryQuery?: never
+ }
+ | {
+ baselineEntry?: never
+ /** Contentful entry ID fetched through the SDK-managed Contentful client. */
+ entryId: string
+ /** Per-call Contentful `getEntry()` query overrides. */
+ entryQuery?: ContentfulEntryQuery
+ }
+
+/**
+ * Props for the {@link OptimizedEntry} component.
+ *
+ * @public
+ */
+export type OptimizedEntryProps = OptimizedEntrySharedProps & OptimizedEntrySourceProps
+
function resolveTapsEnabled(
trackTaps: boolean | undefined,
onTap: ((resolvedEntry: Entry) => void) | undefined,
@@ -150,15 +190,129 @@ function resolveTapsEnabled(
return globalTaps
}
+function resolveLoadingFallback(
+ loadingFallback: OptimizedEntryLoadingFallback | undefined,
+): ReactNode {
+ if (typeof loadingFallback === 'function') {
+ return loadingFallback()
+ }
+
+ return loadingFallback
+}
+
+function resolveErrorFallback(
+ errorFallback: OptimizedEntryErrorFallback | undefined,
+ error: Error,
+): ReactNode {
+ if (typeof errorFallback === 'function') {
+ return errorFallback(error)
+ }
+
+ return errorFallback
+}
+
+function renderFallback(content: ReactNode): React.JSX.Element | null {
+ return content === undefined || content === null ? null : <>{content}>
+}
+
+function resolveChildren(
+ children: OptimizedEntryChildren,
+ entry: Entry,
+ metadata: OptimizedEntryMetadata,
+): ReactNode {
+ return typeof children === 'function' ? children(entry, metadata) : children
+}
+
+function resolveUseOptimizedEntryParams(
+ entryProps: OptimizedEntrySourceProps,
+ liveUpdates: boolean | undefined,
+ onEntryError: ((error: Error) => void) | undefined,
+ onEntryResolved: ((metadata: OptimizedEntryMetadata) => void) | undefined,
+): UseOptimizedEntryParams {
+ if (entryProps.baselineEntry !== undefined) {
+ return { baselineEntry: entryProps.baselineEntry, liveUpdates, onEntryError, onEntryResolved }
+ }
+
+ return {
+ entryId: entryProps.entryId,
+ entryQuery: entryProps.entryQuery,
+ liveUpdates,
+ onEntryError,
+ onEntryResolved,
+ }
+}
+
+interface OptimizedEntryContentProps {
+ readonly children: OptimizedEntryChildren
+ readonly dwellTimeMs?: number
+ readonly minVisibleRatio?: number
+ readonly metadata: OptimizedEntryMetadata
+ readonly onTap?: (resolvedEntry: Entry) => void
+ readonly resolvedData: ResolvedData
+ readonly style?: StyleProp
+ readonly testID?: string
+ readonly trackTaps?: boolean
+ readonly trackViews?: boolean
+ readonly viewDurationUpdateIntervalMs?: number
+}
+
+function OptimizedEntryContent({
+ children,
+ dwellTimeMs,
+ minVisibleRatio,
+ metadata,
+ onTap,
+ resolvedData,
+ style,
+ testID,
+ trackTaps,
+ trackViews,
+ viewDurationUpdateIntervalMs,
+}: OptimizedEntryContentProps): React.JSX.Element {
+ const interactionTracking = useInteractionTracking()
+ const viewsEnabled = trackViews ?? interactionTracking.views
+ const tapsEnabled = resolveTapsEnabled(trackTaps, onTap, interactionTracking.taps)
+
+ const { onLayout } = useViewportTracking({
+ entry: resolvedData.entry,
+ optimizationContextId: resolvedData.optimizationContextId,
+ selectedOptimization: resolvedData.selectedOptimization,
+ dwellTimeMs,
+ minVisibleRatio,
+ viewDurationUpdateIntervalMs,
+ enabled: viewsEnabled,
+ })
+
+ const { onTouchStart, onTouchEnd } = useTapTracking({
+ entry: resolvedData.entry,
+ optimizationContextId: resolvedData.optimizationContextId,
+ selectedOptimization: resolvedData.selectedOptimization,
+ enabled: tapsEnabled,
+ onTap,
+ })
+
+ return (
+
+ {resolveChildren(children, resolvedData.entry, metadata)}
+
+ )
+}
+
/**
* Unified component for tracking and personalizing Contentful entries.
*
* Handles both optimized entries (with `nt_experiences`) and non-optimized
* entries. For optimized entries, it resolves the correct variant based on the
- * user's profile. For all entries, it tracks views and taps.
+ * user's profile. For all resolved entries, it tracks views and taps.
*
* @param props - {@link OptimizedEntryProps}
- * @returns A wrapper View with interaction tracking attached.
+ * @returns A wrapper View with interaction tracking attached after a real entry exists.
*
* @remarks
* "Tracking" refers to tracking Contentful content entries,
@@ -170,7 +324,18 @@ function resolveTapsEnabled(
* flashing. Set `liveUpdates` to `true` or open the preview panel to enable
* real-time variant switching.
*
- * @example Basic usage with render prop
+ * Configure `contentful.client` on {@link OptimizationRoot} or
+ * {@link OptimizationProvider} to let `entryId` fetch the baseline entry through the SDK.
+ * Passing `baselineEntry` keeps manual application-owned fetching behavior unchanged.
+ *
+ * @example SDK-managed entry fetching
+ * ```tsx
+ *
+ * {(resolvedEntry) => }
+ *
+ * ```
+ *
+ * @example Manual baseline entry with render prop
* ```tsx
*
*
@@ -208,8 +373,11 @@ function resolveTapsEnabled(
* @public
*/
export function OptimizedEntry({
- baselineEntry,
children,
+ loadingFallback,
+ errorFallback,
+ onEntryError,
+ onEntryResolved,
dwellTimeMs,
minVisibleRatio,
viewDurationUpdateIntervalMs,
@@ -219,87 +387,34 @@ export function OptimizedEntry({
trackViews,
trackTaps,
onTap,
-}: OptimizedEntryProps): React.JSX.Element {
- const contentfulOptimization = useOptimization()
- const liveUpdatesContext = useLiveUpdates()
- const interactionTracking = useInteractionTracking()
-
- const isOptimized = isResolvedOptimizedEntry(baselineEntry)
-
- const shouldLiveUpdate =
- liveUpdatesContext?.previewPanelVisible === true ||
- (liveUpdates ?? liveUpdatesContext?.globalLiveUpdates ?? false)
-
- const [lockedSelectedOptimizations, setLockedSelectedOptimizations] = useState<
- SelectedOptimizationArray | undefined
- >(undefined)
-
- const isLockedRef = useRef(false)
-
- useEffect(() => {
- if (shouldLiveUpdate) {
- isLockedRef.current = false
- }
- }, [shouldLiveUpdate])
-
- useEffect(() => {
- if (!isOptimized) return
-
- const subscription = contentfulOptimization.states.selectedOptimizations.subscribe(
- (nextSelectedOptimizations) => {
- if (shouldLiveUpdate) {
- setLockedSelectedOptimizations(nextSelectedOptimizations)
- } else if (!isLockedRef.current && nextSelectedOptimizations !== undefined) {
- isLockedRef.current = true
- setLockedSelectedOptimizations(nextSelectedOptimizations)
- }
- },
- )
-
- return () => {
- subscription.unsubscribe()
- }
- }, [contentfulOptimization, shouldLiveUpdate, isOptimized])
-
- const resolvedData: ResolvedData = useMemo(
- () =>
- isOptimized
- ? contentfulOptimization.resolveOptimizedEntry(baselineEntry, lockedSelectedOptimizations)
- : { entry: baselineEntry },
- [baselineEntry, contentfulOptimization, lockedSelectedOptimizations, isOptimized],
+ ...entryProps
+}: OptimizedEntryProps): React.JSX.Element | null {
+ const optimizedEntry = useOptimizedEntry(
+ resolveUseOptimizedEntryParams(entryProps, liveUpdates, onEntryError, onEntryResolved),
)
- const viewsEnabled = trackViews ?? interactionTracking.views
- const tapsEnabled = resolveTapsEnabled(trackTaps, onTap, interactionTracking.taps)
+ if (optimizedEntry.error !== undefined) {
+ return renderFallback(resolveErrorFallback(errorFallback, optimizedEntry.error))
+ }
- const { onLayout } = useViewportTracking({
- entry: resolvedData.entry,
- optimizationContextId: resolvedData.optimizationContextId,
- selectedOptimization: resolvedData.selectedOptimization,
- dwellTimeMs,
- minVisibleRatio,
- viewDurationUpdateIntervalMs,
- enabled: viewsEnabled,
- })
-
- const { onTouchStart, onTouchEnd } = useTapTracking({
- entry: resolvedData.entry,
- optimizationContextId: resolvedData.optimizationContextId,
- selectedOptimization: resolvedData.selectedOptimization,
- enabled: tapsEnabled,
- onTap,
- })
+ if (optimizedEntry.entry === undefined || optimizedEntry.metadata === undefined) {
+ return renderFallback(resolveLoadingFallback(loadingFallback))
+ }
return (
-
- {typeof children === 'function' ? children(resolvedData.entry) : children}
-
+ trackTaps={trackTaps}
+ trackViews={trackViews}
+ viewDurationUpdateIntervalMs={viewDurationUpdateIntervalMs}
+ />
)
}
diff --git a/packages/react-native-sdk/src/hooks/useOptimizedEntry.test.tsx b/packages/react-native-sdk/src/hooks/useOptimizedEntry.test.tsx
new file mode 100644
index 000000000..aab05d56d
--- /dev/null
+++ b/packages/react-native-sdk/src/hooks/useOptimizedEntry.test.tsx
@@ -0,0 +1,210 @@
+import type { ResolvedData } from '@contentful/optimization-core'
+import { afterEach, beforeEach, describe, expect, it, rs } from '@rstest/core'
+import type { Entry, EntrySkeletonType } from 'contentful'
+import React, { act } from 'react'
+import { loadTestRenderer } from '../test/testRenderer'
+import {
+ useOptimizedEntry,
+ type UseOptimizedEntryParams,
+ type UseOptimizedEntryResult,
+} from './useOptimizedEntry'
+
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
+
+const selectedOptimizations = {
+ current: undefined,
+ subscribe: rs.fn(() => ({ unsubscribe: rs.fn() })),
+}
+const fetchContentfulEntry = rs.fn(
+ async (entryId: string) => await Promise.resolve(createEntry(entryId)),
+)
+const resolveOptimizedEntry = rs.fn((entry: Entry): ResolvedData => ({ entry }))
+const optimization = {
+ fetchContentfulEntry,
+ resolveOptimizedEntry,
+ states: {
+ selectedOptimizations,
+ },
+}
+
+rs.mock('../context/OptimizationContext', () => ({
+ useOptimization: () => optimization,
+}))
+
+interface TestRenderer {
+ unmount: () => void
+}
+
+function createEntry(id: string): Entry {
+ return {
+ sys: {
+ id,
+ type: 'Entry',
+ contentType: { sys: { id: 'testType', type: 'Link', linkType: 'ContentType' } },
+ createdAt: '2025-01-01T00:00:00Z',
+ updatedAt: '2025-01-01T00:00:00Z',
+ environment: { sys: { id: 'master', type: 'Link', linkType: 'Environment' } },
+ publishedVersion: 1,
+ space: { sys: { id: 'space1', type: 'Link', linkType: 'Space' } },
+ revision: 1,
+ locale: 'en-US',
+ },
+ fields: { title: id },
+ metadata: { concepts: [], tags: [] },
+ }
+}
+
+function createDeferred(): {
+ readonly promise: Promise
+ readonly reject: (reason?: unknown) => void
+ readonly resolve: (value: T) => void
+} {
+ let resolveDeferred: (value: T) => void = () => undefined
+ let rejectDeferred: (reason?: unknown) => void = () => undefined
+ const promise = new Promise((resolve, reject) => {
+ resolveDeferred = resolve
+ rejectDeferred = reject
+ })
+
+ return { promise, reject: rejectDeferred, resolve: resolveDeferred }
+}
+
+async function renderHook(
+ params: UseOptimizedEntryParams,
+): Promise<{ getResult: () => UseOptimizedEntryResult; unmount: () => void }> {
+ const testRenderer = await loadTestRenderer()
+ let captured: UseOptimizedEntryResult | undefined = undefined
+ let renderer: TestRenderer | undefined = undefined
+
+ function Probe(): null {
+ captured = useOptimizedEntry(params)
+ return null
+ }
+
+ act(() => {
+ renderer = testRenderer.create()
+ })
+
+ return {
+ getResult() {
+ if (captured === undefined) {
+ throw new Error('Expected hook result to be captured')
+ }
+
+ return captured
+ },
+ unmount() {
+ renderer?.unmount()
+ },
+ }
+}
+
+describe('useOptimizedEntry', () => {
+ let unmount: (() => void) | undefined = undefined
+
+ beforeEach(() => {
+ rs.clearAllMocks()
+ selectedOptimizations.current = undefined
+ fetchContentfulEntry.mockImplementation(
+ async (entryId: string) => await Promise.resolve(createEntry(entryId)),
+ )
+ resolveOptimizedEntry.mockImplementation(
+ (entry: Entry): ResolvedData => ({
+ entry,
+ }),
+ )
+ })
+
+ afterEach(() => {
+ if (unmount) {
+ act(() => {
+ unmount?.()
+ })
+ unmount = undefined
+ }
+ })
+
+ it('returns manual baseline entries synchronously', async () => {
+ const baselineEntry = createEntry('baseline')
+ const rendered = await renderHook({ baselineEntry })
+ unmount = rendered.unmount
+
+ expect(rendered.getResult()).toMatchObject({
+ entry: baselineEntry,
+ baselineEntry,
+ error: undefined,
+ isLoading: false,
+ isReady: true,
+ })
+ })
+
+ it('fetches managed entryId entries with query options', async () => {
+ const baselineEntry = createEntry('baseline')
+ const deferred = createDeferred()
+ const onEntryResolved = rs.fn()
+ fetchContentfulEntry.mockImplementation(async () => await deferred.promise)
+ const rendered = await renderHook({
+ entryId: 'baseline',
+ entryQuery: { locale: 'de-DE' },
+ onEntryResolved,
+ })
+ unmount = rendered.unmount
+
+ expect(rendered.getResult()).toMatchObject({
+ entry: undefined,
+ baselineEntry: undefined,
+ isLoading: true,
+ isReady: false,
+ })
+ expect(fetchContentfulEntry).toHaveBeenCalledWith('baseline', { locale: 'de-DE' })
+
+ await act(async () => {
+ deferred.resolve(baselineEntry)
+ await deferred.promise
+ })
+
+ expect(rendered.getResult()).toMatchObject({
+ entry: baselineEntry,
+ baselineEntry,
+ error: undefined,
+ isLoading: false,
+ isReady: true,
+ isResolved: true,
+ metadata: {
+ baselineEntry,
+ baselineEntryId: 'baseline',
+ entry: baselineEntry,
+ entryId: 'baseline',
+ },
+ })
+ expect(onEntryResolved).toHaveBeenCalledWith(
+ expect.objectContaining({
+ baselineEntry,
+ entry: baselineEntry,
+ }),
+ )
+ })
+
+ it('surfaces managed entryId fetch errors once', async () => {
+ const error = new Error('CDA failed')
+ const onEntryError = rs.fn()
+ fetchContentfulEntry.mockImplementation(async () => await Promise.reject(error))
+ const rendered = await renderHook({ entryId: 'baseline', onEntryError })
+ unmount = rendered.unmount
+
+ await act(async () => {
+ await Promise.resolve()
+ await Promise.resolve()
+ })
+
+ expect(onEntryError).toHaveBeenCalledTimes(1)
+ expect(onEntryError).toHaveBeenCalledWith(error)
+ expect(rendered.getResult()).toMatchObject({
+ entry: undefined,
+ baselineEntry: undefined,
+ error,
+ isLoading: false,
+ isReady: false,
+ })
+ })
+})
diff --git a/packages/react-native-sdk/src/hooks/useOptimizedEntry.ts b/packages/react-native-sdk/src/hooks/useOptimizedEntry.ts
new file mode 100644
index 000000000..9227c8faf
--- /dev/null
+++ b/packages/react-native-sdk/src/hooks/useOptimizedEntry.ts
@@ -0,0 +1,260 @@
+import type { OptimizedEntryMetadata, ResolvedData } from '@contentful/optimization-core'
+import type { SelectedOptimizationArray } from '@contentful/optimization-core/api-schemas'
+import { isResolvedOptimizedEntry } from '@contentful/optimization-core/api-schemas'
+import {
+ createOptimizedEntryLoadingEntry,
+ OptimizedEntrySourceController,
+ type ContentfulEntryQuery,
+ type OptimizedEntrySourceSnapshot,
+} from '@contentful/optimization-core/entry-source'
+import type { Entry, EntrySkeletonType } from 'contentful'
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { useLiveUpdates } from '../context/LiveUpdatesContext'
+import { useOptimization } from '../context/OptimizationContext'
+
+/**
+ * Source and behavior options for {@link useOptimizedEntry}.
+ *
+ * @public
+ */
+export type UseOptimizedEntryParams = {
+ liveUpdates?: boolean
+ onEntryError?: (error: Error) => void
+ onEntryResolved?: (metadata: OptimizedEntryMetadata) => void
+} & (
+ | {
+ baselineEntry: Entry
+ entryId?: never
+ entryQuery?: never
+ }
+ | {
+ baselineEntry?: never
+ entryId: string
+ entryQuery?: ContentfulEntryQuery
+ }
+)
+
+type UseOptimizedEntryBaselineParams = Extract
+
+interface UseManagedBaselineEntryResult {
+ readonly entry: Entry | undefined
+ readonly error: Error | undefined
+ readonly isLoading: boolean
+}
+
+/**
+ * Resolved entry state returned by {@link useOptimizedEntry}.
+ *
+ * @public
+ */
+export interface UseOptimizedEntryResult {
+ readonly entry: TEntry
+ readonly baselineEntry: TEntry
+ readonly error: Error | undefined
+ readonly isLoading: boolean
+ readonly isReady: boolean
+ readonly isResolved: boolean
+ readonly metadata: OptimizedEntryMetadata | undefined
+ readonly selectedOptimization: ResolvedData['selectedOptimization']
+ readonly resolvedData: ResolvedData
+ readonly selectedOptimizations: SelectedOptimizationArray | undefined
+}
+
+function getEntryQueryKey(query: ContentfulEntryQuery | undefined): string {
+ return JSON.stringify(query ?? {})
+}
+
+function useManagedBaselineEntry({
+ baselineEntry,
+ entryId,
+ entryQuery,
+ onEntryError,
+}: UseOptimizedEntryParams): UseManagedBaselineEntryResult {
+ const sdk = useOptimization()
+ const entryQueryKey = getEntryQueryKey(entryQuery)
+ const [controller] = useState(() => new OptimizedEntrySourceController())
+ const [snapshot, setSnapshot] = useState(() => {
+ if (baselineEntry !== undefined) {
+ return { baselineEntry, isLoading: false }
+ }
+
+ return { entryId, isLoading: true }
+ })
+ const reportedErrorRef = useRef(undefined)
+
+ useEffect(() => {
+ controller.setSnapshotListener(setSnapshot)
+
+ return () => {
+ controller.setSnapshotListener(undefined)
+ controller.disconnect()
+ }
+ }, [controller])
+
+ useEffect(() => {
+ controller.updateOptions({
+ baselineEntry,
+ entryId,
+ entryQuery,
+ sdk,
+ isSdkStateReady: true,
+ })
+ }, [baselineEntry, controller, entryId, entryQuery, entryQueryKey, sdk])
+
+ useEffect(() => {
+ const { error } = snapshot
+ if (error === undefined) {
+ reportedErrorRef.current = undefined
+ return
+ }
+
+ if (reportedErrorRef.current === error) {
+ return
+ }
+
+ reportedErrorRef.current = error
+ onEntryError?.(error)
+ }, [onEntryError, snapshot])
+
+ if (baselineEntry !== undefined) {
+ return { entry: baselineEntry, error: undefined, isLoading: false }
+ }
+
+ return {
+ entry: snapshot.baselineEntry,
+ error: snapshot.error,
+ isLoading: snapshot.isLoading,
+ }
+}
+
+function useResolvedEntryData(
+ baselineEntry: Entry | undefined,
+ loadingEntry: Entry,
+ liveUpdates: boolean | undefined,
+): {
+ readonly resolvedData: ResolvedData
+ readonly selectedOptimizations: SelectedOptimizationArray | undefined
+} {
+ const sdk = useOptimization()
+ const liveUpdatesContext = useLiveUpdates()
+ const isOptimized = baselineEntry !== undefined && isResolvedOptimizedEntry(baselineEntry)
+ const shouldLiveUpdate =
+ liveUpdatesContext?.previewPanelVisible === true ||
+ (liveUpdates ?? liveUpdatesContext?.globalLiveUpdates ?? false)
+ const [lockedSelectedOptimizations, setLockedSelectedOptimizations] = useState<
+ SelectedOptimizationArray | undefined
+ >(undefined)
+ const isLockedRef = useRef(false)
+
+ useEffect(() => {
+ if (shouldLiveUpdate) {
+ isLockedRef.current = false
+ }
+ }, [shouldLiveUpdate])
+
+ useEffect(() => {
+ if (!isOptimized) return
+
+ const subscription = sdk.states.selectedOptimizations.subscribe((nextSelectedOptimizations) => {
+ if (shouldLiveUpdate) {
+ setLockedSelectedOptimizations(nextSelectedOptimizations)
+ } else if (!isLockedRef.current && nextSelectedOptimizations !== undefined) {
+ isLockedRef.current = true
+ setLockedSelectedOptimizations(nextSelectedOptimizations)
+ }
+ })
+
+ return () => {
+ subscription.unsubscribe()
+ }
+ }, [sdk, shouldLiveUpdate, isOptimized])
+
+ const resolvedData: ResolvedData = useMemo(() => {
+ if (baselineEntry === undefined) {
+ return { entry: loadingEntry }
+ }
+
+ return isOptimized
+ ? sdk.resolveOptimizedEntry(baselineEntry, lockedSelectedOptimizations)
+ : { entry: baselineEntry }
+ }, [baselineEntry, isOptimized, loadingEntry, lockedSelectedOptimizations, sdk])
+
+ return {
+ resolvedData,
+ selectedOptimizations: isOptimized ? lockedSelectedOptimizations : undefined,
+ }
+}
+
+/**
+ * Fetches or accepts a baseline Contentful entry, resolves the selected variant, and returns
+ * render-ready entry state for React Native components.
+ *
+ * @remarks
+ * Pass `entryId` when the SDK is configured with `contentful.client`. Pass `baselineEntry` to keep
+ * manual application-owned Contentful fetching unchanged.
+ *
+ * @public
+ */
+export function useOptimizedEntry(
+ params: UseOptimizedEntryBaselineParams,
+): UseOptimizedEntryResult
+export function useOptimizedEntry(params: UseOptimizedEntryParams): UseOptimizedEntryResult
+export function useOptimizedEntry(params: UseOptimizedEntryParams): UseOptimizedEntryResult {
+ const managedEntry = useManagedBaselineEntry(params)
+ const loadingEntryId = (params as { readonly entryId?: string }).entryId ?? 'contentful-entry'
+ const loadingEntry = useMemo(
+ () => createOptimizedEntryLoadingEntry(loadingEntryId),
+ [loadingEntryId],
+ )
+ const { resolvedData, selectedOptimizations } = useResolvedEntryData(
+ managedEntry.entry,
+ loadingEntry,
+ params.liveUpdates,
+ )
+ const hasEntry = managedEntry.entry !== undefined
+ const metadata = useMemo(
+ () =>
+ hasEntry
+ ? {
+ baselineEntry: managedEntry.entry,
+ baselineEntryId: managedEntry.entry.sys.id,
+ entry: resolvedData.entry,
+ entryId: resolvedData.entry.sys.id,
+ optimizationContextId: resolvedData.optimizationContextId,
+ resolvedData,
+ selectedOptimization: resolvedData.selectedOptimization,
+ selectedOptimizations,
+ }
+ : undefined,
+ [hasEntry, managedEntry.entry, resolvedData, selectedOptimizations],
+ )
+ const { onEntryResolved } = params
+ const lastResolvedMetadataRef = useRef(undefined)
+
+ useEffect(() => {
+ if (metadata === undefined) {
+ lastResolvedMetadataRef.current = undefined
+ return
+ }
+
+ if (lastResolvedMetadataRef.current === metadata) {
+ return
+ }
+
+ lastResolvedMetadataRef.current = metadata
+ onEntryResolved?.(metadata)
+ }, [metadata, onEntryResolved])
+
+ return {
+ entry: hasEntry ? resolvedData.entry : undefined,
+ baselineEntry: managedEntry.entry,
+ error: managedEntry.error,
+ isLoading: managedEntry.isLoading,
+ isReady: hasEntry,
+ isResolved: hasEntry,
+ metadata,
+ selectedOptimization: hasEntry ? resolvedData.selectedOptimization : undefined,
+ resolvedData,
+ selectedOptimizations: hasEntry ? selectedOptimizations : undefined,
+ }
+}
diff --git a/packages/react-native-sdk/src/index.ts b/packages/react-native-sdk/src/index.ts
index ba678d66c..9e807adf0 100644
--- a/packages/react-native-sdk/src/index.ts
+++ b/packages/react-native-sdk/src/index.ts
@@ -62,6 +62,9 @@ export type {
export { useEntryResolver } from './hooks/useEntryResolver'
export type { UseEntryResolverResult } from './hooks/useEntryResolver'
+export { useOptimizedEntry } from './hooks/useOptimizedEntry'
+export type { UseOptimizedEntryParams, UseOptimizedEntryResult } from './hooks/useOptimizedEntry'
+
export { useViewportTracking } from './hooks/useViewportTracking'
export type {
UseViewportTrackingOptions,
diff --git a/packages/universal/api-schemas/README.md b/packages/universal/api-schemas/README.md
index 50c7efb3b..e4a85bf34 100644
--- a/packages/universal/api-schemas/README.md
+++ b/packages/universal/api-schemas/README.md
@@ -83,10 +83,10 @@ These helpers identify and normalize Optimization-owned Contentful fields:
| `isResolvedOptimizationEntry` | Structural guard for resolved optimization entries |
| `normalizeOptimizationConfig` | Fills omitted optimization config fields with SDK-safe defaults |
-These schemas model the SDK's single-locale CDA entry contract. Fetch entries in the app layer with
-one application Contentful locale before passing entries to SDK resolution helpers. Avoid
-`withAllLocales` or `locale=*` in that path. See
-[Entry optimization and variant resolution](https://contentful.github.io/optimization/documents/Documentation.Concepts.Entry_optimization_and_variant_resolution.html#single-locale-cda-entry-contract)
+These schemas model the SDK's single-locale CDA entry contract. Manual resolution passes entries
+fetched with one app Contentful locale. JS SDK-managed fetching uses the same contract when a
+`contentful.js` client is configured. Avoid `withAllLocales` or `locale=*` in either path. See
+[Entry personalization and variant resolution](https://contentful.github.io/optimization/documents/Documentation.Concepts.Entry_personalization_and_variant_resolution.html#single-locale-cda-entry-contract)
for the entry contract and
[Locale handling in the Optimization SDK Suite](https://contentful.github.io/optimization/documents/Documentation.Concepts.Locale_handling_in_the_Optimization_SDK_Suite.html)
for the broader locale model.
diff --git a/packages/universal/core-sdk/README.md b/packages/universal/core-sdk/README.md
index 1e7b7b90a..3c6a73491 100644
--- a/packages/universal/core-sdk/README.md
+++ b/packages/universal/core-sdk/README.md
@@ -41,6 +41,7 @@ exported API signatures.
- [Stateless Core](#stateless-core)
- [Common configuration](#common-configuration)
- [Package surface](#package-surface)
+ - [Custom entry-source adapters](#custom-entry-source-adapters)
- [Preview support](#preview-support)
- [Related](#related)
@@ -115,6 +116,7 @@ Shared Core configuration:
| `clientId` | Yes | N/A | Shared API key for Experience API and Insights API requests |
| `environment` | No | `'main'` | Contentful environment identifier |
| `api` | No | See API options below | Experience API and Insights API endpoint options |
+| `contentful` | No | `undefined` | App-owned `contentful.js` client, default query, and cache |
| `eventBuilder` | No | SDK-layer defaults | Event metadata overrides for platform SDK authors |
| `fetchOptions` | No | SDK defaults | Fetch timeout and retry behavior |
| `logLevel` | No | `'error'` | Minimum log level for the default console sink |
@@ -181,27 +183,54 @@ For every option, callback payload, and exported type, use the generated
Core exposes reusable primitives for SDK layers:
-| Surface | Purpose |
-| ------------------------------- | ---------------------------------------------------------------------- |
-| `CoreStateful` | Stateful optimization runtime for browser, mobile, and bridge SDKs |
-| `CoreStateless` | Stateless optimization runtime for server SDKs |
-| Event methods | `identify`, `page`, `screen`, `track`, `trackView`, `trackClick`, etc. |
-| Resolution helpers | `resolveOptimizedEntry`, `getMergeTagValue`, and `getFlag` |
-| Current-state tracking | `AcceptedCurrentStateTracker` for SDK-owned page or screen adapters |
-| `states` | Stateful observable state streams |
-| Interceptors | First-party hooks for event and state lifecycle customization |
-| Queue policy and fetch helpers | Shared retry, flush, timeout, and offline buffering behavior |
-| Signal and observable utilities | Lightweight reactive primitives used internally by stateful SDK layers |
-
-Resolution helpers expect Contentful entries fetched by the app layer with one CDA locale. Use the
-application Contentful locale before resolving entries. Do not pass all-locale CDA responses from
-`withAllLocales` or `locale=*`; optimization fields such as `fields.nt_experiences` and
-`fields.nt_variants` must be direct single-locale field values. See
+| Surface | Purpose |
+| ------------------------------- | --------------------------------------------------------------------------------- |
+| `CoreStateful` | Stateful optimization runtime for browser, mobile, and bridge SDKs |
+| `CoreStateless` | Stateless optimization runtime for server SDKs |
+| Event methods | `identify`, `page`, `screen`, `track`, `trackView`, `trackClick`, etc. |
+| Resolution and fetch helpers | `resolveOptimizedEntry`, `fetchOptimizedEntry`, `getMergeTagValue`, and `getFlag` |
+| Current-state tracking | `AcceptedCurrentStateTracker` for SDK-owned page or screen adapters |
+| `states` | Stateful observable state streams |
+| Interceptors | First-party hooks for event and state lifecycle customization |
+| Queue policy and fetch helpers | Shared retry, flush, timeout, and offline buffering behavior |
+| Signal and observable utilities | Lightweight reactive primitives used internally by stateful SDK layers |
+
+When a `contentful.js` client is available, prefer SDK-managed fetching. Configure
+`contentful: { client, defaultQuery?, cache? }`, then call `fetchContentfulEntry(entryId, query?)`
+or `fetchOptimizedEntry(entryId, options?)`. Managed calls merge `defaultQuery`, per-call query
+overrides, SDK `locale` fallback, and `include: 10`, then cache entries per SDK instance by default.
+Set `contentful.cache: false` to disable the cache or call `clearContentfulEntryCache()` to clear
+it. `resolveOptimizedEntry()` remains the manual path for entries the app already fetched. Stateful
+Core uses the current `selectedOptimizations` when omitted, request-bound stateless clients use the
+latest accepted Experience selections and request `locale` fallback for managed Contentful fetches,
+and root stateless callers pass explicit `selectedOptimizations`.
+
+Do not pass all-locale CDA responses from `withAllLocales` or `locale=*`; optimization fields such
+as `fields.nt_experiences` and `fields.nt_variants` must be direct single-locale field values. See
[Entry personalization and variant resolution](https://contentful.github.io/optimization/documents/Documentation.Concepts.Entry_personalization_and_variant_resolution.html#single-locale-cda-entry-contract)
for the entry contract and
[Locale handling in the Optimization SDK Suite](https://contentful.github.io/optimization/documents/Documentation.Concepts.Locale_handling_in_the_Optimization_SDK_Suite.html)
for the broader locale model.
+### Custom entry-source adapters
+
+`@contentful/optimization-core/entry-source` is an advanced subpath for custom JavaScript runtime or
+framework adapters that cannot use an official Web, React Web, React Native, Node, or native SDK
+surface. It is not part of the root Core API posture and is not the preferred path for application
+integrations.
+
+Import `OptimizedEntrySourceController` when an adapter accepts either a direct `baselineEntry` or
+an `entryId` that must be fetched through an SDK-managed `contentful.js` client. The controller owns
+the `baselineEntry` versus `entryId` source lifecycle, `entryId + entryQuery` fetch keying, SDK
+readiness/loading/error snapshots, stale request protection, and `disconnect()` cleanup.
+`createOptimizedEntryLoadingEntry(entryId)` creates a stable placeholder Contentful entry for
+adapter loading states.
+
+The entry-source controller does not own rendering, variant resolution, tracking, consent,
+Experience API calls, or Contentful client creation. After a snapshot contains `baselineEntry`, the
+adapter still calls `resolveOptimizedEntry()`, renders the result, and wires any runtime-specific
+tracking.
+
The generated reference owns method arguments, return types, callback payload shapes, and inherited
members. Keep this README focused on package role and maintainer orientation.
diff --git a/packages/universal/core-sdk/package.json b/packages/universal/core-sdk/package.json
index 2d1bb7630..1be243564 100644
--- a/packages/universal/core-sdk/package.json
+++ b/packages/universal/core-sdk/package.json
@@ -52,6 +52,16 @@
"default": "./dist/bridge-support.cjs"
}
},
+ "./entry-source": {
+ "import": {
+ "types": "./dist/entry-source.d.mts",
+ "default": "./dist/entry-source.mjs"
+ },
+ "require": {
+ "types": "./dist/entry-source.d.cts",
+ "default": "./dist/entry-source.cjs"
+ }
+ },
"./api-client": {
"import": {
"types": "./dist/api-client.d.mts",
@@ -91,8 +101,8 @@
"buildTools": {
"bundleSize": {
"gzipBudgets": {
- "index.cjs": 18300,
- "index.mjs": 17800,
+ "index.cjs": 19100,
+ "index.mjs": 18700,
"bridge-support.cjs": 1200,
"bridge-support.mjs": 2100,
"preview-support.cjs": 5300,
diff --git a/packages/universal/core-sdk/rslib.config.ts b/packages/universal/core-sdk/rslib.config.ts
index b4410b9f8..445dc02c0 100644
--- a/packages/universal/core-sdk/rslib.config.ts
+++ b/packages/universal/core-sdk/rslib.config.ts
@@ -21,6 +21,7 @@ export default defineConfig({
logger: './src/logger.ts',
constants: './src/constants.ts',
'bridge-support': './src/bridge-support/index.ts',
+ 'entry-source': './src/entry-source.ts',
'api-client': './src/api-client.ts',
'api-schemas': './src/api-schemas.ts',
'preview-support': './src/preview-support/index.ts',
diff --git a/packages/universal/core-sdk/src/CoreBase.test.ts b/packages/universal/core-sdk/src/CoreBase.test.ts
index 40f2dbd00..ebf13a5f4 100644
--- a/packages/universal/core-sdk/src/CoreBase.test.ts
+++ b/packages/universal/core-sdk/src/CoreBase.test.ts
@@ -1,9 +1,16 @@
import type { ApiClientConfig } from '@contentful/optimization-api-client'
import { EXPERIENCE_BASE_URL } from '@contentful/optimization-api-client'
+import { createClient, type Entry, type EntryFieldTypes, type EntrySkeletonType } from 'contentful'
import type { ChangeArray } from './api-schemas'
import { OPTIMIZATION_CORE_SDK_NAME } from './constants'
-import CoreBase, { type CoreConfig } from './CoreBase'
+import CoreBase, {
+ type ContentfulEntryClient,
+ type ContentfulEntryQuery,
+ type CoreConfig,
+} from './CoreBase'
import { FlagsResolver } from './resolvers'
+import { optimizedEntry } from './test/fixtures/optimizedEntry'
+import { selectedOptimizations } from './test/fixtures/selectedOptimizations'
class TestCore extends CoreBase {
constructor(
@@ -22,6 +29,13 @@ class TestCore extends CoreBase {
const CLIENT_ID = 'key_123'
const ENVIRONMENT = 'main'
+type MockContentfulGetEntry = (entryId: string, query?: ContentfulEntryQuery) => Promise
+type ProductEntrySkeleton = EntrySkeletonType<
+ {
+ title: EntryFieldTypes.Symbol
+ },
+ 'product'
+>
const config: CoreConfig = {
clientId: CLIENT_ID,
environment: ENVIRONMENT,
@@ -53,7 +67,61 @@ const CHANGES: ChangeArray = [
},
]
+function createEntry(id: string): Entry {
+ return {
+ fields: { title: id },
+ metadata: { tags: [] },
+ sys: {
+ contentType: { sys: { id: 'test-content-type', linkType: 'ContentType', type: 'Link' } },
+ createdAt: '2024-01-01T00:00:00.000Z',
+ environment: { sys: { id: 'main', linkType: 'Environment', type: 'Link' } },
+ id,
+ publishedVersion: 1,
+ revision: 1,
+ space: { sys: { id: 'space-id', linkType: 'Space', type: 'Link' } },
+ type: 'Entry',
+ updatedAt: '2024-01-01T00:00:00.000Z',
+ },
+ }
+}
+
+function createContentfulClient(
+ implementation: MockContentfulGetEntry = async (entryId) =>
+ await Promise.resolve(createEntry(entryId)),
+): ContentfulEntryClient & {
+ readonly getEntry: ReturnType>
+} {
+ const getEntry = rs.fn(implementation)
+ const client: ContentfulEntryClient & {
+ readonly getEntry: ReturnType>
+ } = {
+ getEntry,
+ }
+
+ return client
+}
+
+function createDeferred(): {
+ readonly promise: Promise
+ readonly reject: (reason?: unknown) => void
+ readonly resolve: (value: T) => void
+} {
+ let resolveDeferred: (value: T) => void = () => undefined
+ let rejectDeferred: (reason?: unknown) => void = () => undefined
+ const promise = new Promise((resolve, reject) => {
+ resolveDeferred = resolve
+ rejectDeferred = reject
+ })
+
+ return { promise, reject: rejectDeferred, resolve: resolveDeferred }
+}
+
describe('CoreBase', () => {
+ afterEach(() => {
+ rs.restoreAllMocks()
+ rs.useRealTimers()
+ })
+
it('allows access to the original configuration options', () => {
const core = new TestCore(config)
@@ -133,4 +201,208 @@ describe('CoreBase', () => {
expect(core.eventBuilder.buildPageView({}).context.locale).toBe('de-DE')
})
+
+ it('accepts normal contentful.js clients for managed entry fetching config', () => {
+ const normalContentfulClient = createClient({
+ accessToken: 'delivery-token',
+ space: 'space-id',
+ })
+ const typedClient: ContentfulEntryClient = normalContentfulClient
+
+ const core = new TestCore({
+ ...config,
+ contentful: { client: typedClient, cache: false },
+ })
+
+ expect(core.config.contentful?.client).toBe(normalContentfulClient)
+ })
+
+ it('preserves managed Contentful entry skeleton types for fetched entries', async () => {
+ const client = createContentfulClient()
+ const core = new TestCore({
+ ...config,
+ contentful: { client, cache: false },
+ })
+
+ const entry = await core.fetchContentfulEntry('entry-id')
+ const result = await core.fetchOptimizedEntry('entry-id')
+ const typedEntry: Entry = entry
+ const typedBaselineEntry: Entry = result.baselineEntry
+ const typedResolvedEntry: Entry = result.entry
+ const typedTitle: string = result.entry.fields.title
+
+ expect(typedEntry).toBe(entry)
+ expect(typedBaselineEntry).toBe(result.baselineEntry)
+ expect(typedResolvedEntry).toBe(result.entry)
+ expect(typedTitle).toBe(result.entry.fields.title)
+ })
+
+ it('merges Contentful entry query defaults, SDK locale, include depth, and per-call overrides', async () => {
+ const client = createContentfulClient()
+ const core = new TestCore(
+ {
+ ...config,
+ contentful: {
+ client,
+ defaultQuery: { include: 2, locale: 'en-US' },
+ cache: false,
+ },
+ },
+ {},
+ 'de-DE',
+ )
+
+ await core.fetchContentfulEntry('entry-a', { locale: 'fr-FR' })
+ await core.fetchContentfulEntry('entry-b', { include: 1 })
+
+ expect(client.getEntry).toHaveBeenNthCalledWith(1, 'entry-a', {
+ include: 2,
+ locale: 'fr-FR',
+ })
+ expect(client.getEntry).toHaveBeenNthCalledWith(2, 'entry-b', {
+ include: 1,
+ locale: 'en-US',
+ })
+ })
+
+ it('uses SDK locale and include 10 when Contentful entry query omits them', async () => {
+ const client = createContentfulClient()
+ const core = new TestCore(
+ {
+ ...config,
+ contentful: { client, cache: false },
+ },
+ {},
+ 'de-DE',
+ )
+
+ await core.fetchContentfulEntry('entry-id')
+
+ expect(client.getEntry).toHaveBeenCalledWith('entry-id', {
+ include: 10,
+ locale: 'de-DE',
+ })
+ })
+
+ it('throws when managed Contentful fetching is used without a client', async () => {
+ const core = new TestCore(config)
+
+ await expect(core.fetchContentfulEntry('entry-id')).rejects.toThrow(
+ 'fetchContentfulEntry() requires contentful.client in SDK config.',
+ )
+ })
+
+ it('caches Contentful entries and in-flight requests by entry ID and merged query', async () => {
+ const deferred = createDeferred()
+ const client = createContentfulClient(async () => await deferred.promise)
+ const core = new TestCore({
+ ...config,
+ contentful: { client },
+ })
+
+ const first = core.fetchContentfulEntry('entry-id')
+ const second = core.fetchContentfulEntry('entry-id')
+
+ expect(client.getEntry).toHaveBeenCalledTimes(1)
+
+ const entry = createEntry('entry-id')
+ deferred.resolve(entry)
+
+ await expect(first).resolves.toBe(entry)
+ await expect(second).resolves.toBe(entry)
+ await expect(core.fetchContentfulEntry('entry-id')).resolves.toBe(entry)
+ expect(client.getEntry).toHaveBeenCalledTimes(1)
+ })
+
+ it('expires cached Contentful entries by TTL', async () => {
+ rs.useFakeTimers()
+ rs.setSystemTime(new Date('2026-01-01T00:00:00.000Z'))
+ const client = createContentfulClient()
+ const core = new TestCore({
+ ...config,
+ contentful: { client, cache: { ttlMs: 1000 } },
+ })
+
+ await core.fetchContentfulEntry('entry-id')
+ rs.setSystemTime(new Date('2026-01-01T00:00:00.999Z'))
+ await core.fetchContentfulEntry('entry-id')
+ rs.setSystemTime(new Date('2026-01-01T00:00:01.001Z'))
+ await core.fetchContentfulEntry('entry-id')
+
+ expect(client.getEntry).toHaveBeenCalledTimes(2)
+ })
+
+ it('evicts least-recently-used Contentful entries when the cache is full', async () => {
+ const client = createContentfulClient()
+ const core = new TestCore({
+ ...config,
+ contentful: { client, cache: { maxEntries: 2 } },
+ })
+
+ await core.fetchContentfulEntry('entry-a')
+ await core.fetchContentfulEntry('entry-b')
+ await core.fetchContentfulEntry('entry-a')
+ await core.fetchContentfulEntry('entry-c')
+ await core.fetchContentfulEntry('entry-b')
+
+ expect(client.getEntry).toHaveBeenCalledTimes(4)
+ })
+
+ it('removes failed Contentful requests from the cache', async () => {
+ const client = createContentfulClient()
+ client.getEntry.mockRejectedValueOnce(new Error('CDA failed'))
+ const core = new TestCore({
+ ...config,
+ contentful: { client },
+ })
+
+ await expect(core.fetchContentfulEntry('entry-id')).rejects.toThrow('CDA failed')
+ await expect(core.fetchContentfulEntry('entry-id')).resolves.toMatchObject({
+ sys: { id: 'entry-id' },
+ })
+
+ expect(client.getEntry).toHaveBeenCalledTimes(2)
+ })
+
+ it('can disable and clear the managed Contentful entry cache', async () => {
+ const uncachedClient = createContentfulClient()
+ const uncachedCore = new TestCore({
+ ...config,
+ contentful: { client: uncachedClient, cache: false },
+ })
+
+ await uncachedCore.fetchContentfulEntry('entry-id')
+ await uncachedCore.fetchContentfulEntry('entry-id')
+ expect(uncachedClient.getEntry).toHaveBeenCalledTimes(2)
+
+ const cachedClient = createContentfulClient()
+ const cachedCore = new TestCore({
+ ...config,
+ contentful: { client: cachedClient },
+ })
+
+ await cachedCore.fetchContentfulEntry('entry-id')
+ cachedCore.clearContentfulEntryCache()
+ await cachedCore.fetchContentfulEntry('entry-id')
+ expect(cachedClient.getEntry).toHaveBeenCalledTimes(2)
+ })
+
+ it('fetches and resolves optimized Contentful entries with metadata', async () => {
+ const client = createContentfulClient(async () => await Promise.resolve(optimizedEntry))
+ const core = new TestCore({
+ ...config,
+ contentful: { client, cache: false },
+ })
+
+ const result = await core.fetchOptimizedEntry('entry-id', { selectedOptimizations })
+
+ expect(result.baselineEntry).toBe(optimizedEntry)
+ expect(result.entry.sys.id).toBe('4k6ZyFQnR2POY5IJLLlJRb')
+ expect(result.selectedOptimization).toEqual(
+ expect.objectContaining({
+ experienceId: '2qVK4T5lnScbswoyBuGipd',
+ variantIndex: 1,
+ }),
+ )
+ })
})
diff --git a/packages/universal/core-sdk/src/CoreBase.ts b/packages/universal/core-sdk/src/CoreBase.ts
index 650f9541c..f71a77885 100644
--- a/packages/universal/core-sdk/src/CoreBase.ts
+++ b/packages/universal/core-sdk/src/CoreBase.ts
@@ -15,13 +15,126 @@ import type {
} from '@contentful/optimization-api-client/api-schemas'
import type { LogLevels } from '@contentful/optimization-api-client/logger'
import { ConsoleLogSink, logger } from '@contentful/optimization-api-client/logger'
-import type { ChainModifiers, Entry, EntrySkeletonType, LocaleCode } from 'contentful'
+import type { ChainModifiers, Entry, EntryQueries, EntrySkeletonType, LocaleCode } from 'contentful'
import { OPTIMIZATION_CORE_SDK_NAME, OPTIMIZATION_CORE_SDK_VERSION } from './constants'
import { EventBuilder, type EventBuilderConfig } from './events'
import { InterceptorManager } from './lib/interceptor'
import type { ResolvedData } from './resolvers'
import { FlagsResolver, MergeTagValueResolver, OptimizedEntryResolver } from './resolvers'
+const DEFAULT_CONTENTFUL_ENTRY_CACHE_MAX_ENTRIES = 100
+const DEFAULT_CONTENTFUL_ENTRY_CACHE_TTL_MS = 300_000
+const DEFAULT_CONTENTFUL_ENTRY_INCLUDE = 10
+
+/**
+ * Query shape used for SDK-managed `contentful.js` `getEntry()` calls.
+ *
+ * @public
+ */
+export type ContentfulEntryQuery = EntryQueries
+
+type ManagedContentfulEntry<
+ S extends EntrySkeletonType = EntrySkeletonType,
+ L extends LocaleCode = LocaleCode,
+> = Entry
+
+/**
+ * Minimal `contentful.js` client surface required for SDK-managed entry fetching.
+ *
+ * @public
+ */
+export interface ContentfulEntryClient {
+ /** Fetch a single Contentful entry by ID. */
+ getEntry: (entryId: string, query?: ContentfulEntryQuery) => Promise
+}
+
+/**
+ * Cache options for SDK-managed Contentful entry fetching.
+ *
+ * @public
+ */
+export interface ContentfulEntryCacheOptions {
+ /** Maximum number of entries retained per SDK instance. */
+ maxEntries?: number
+ /** Time, in milliseconds, before a cached entry is refetched. */
+ ttlMs?: number
+}
+
+/**
+ * SDK-managed Contentful entry fetching configuration.
+ *
+ * @public
+ */
+export interface ContentfulConfig {
+ /** `contentful.js` client used for SDK-managed `getEntry()` calls. */
+ client: ContentfulEntryClient
+ /** Query merged into every SDK-managed `getEntry()` call. */
+ defaultQuery?: ContentfulEntryQuery
+ /**
+ * Per-SDK-instance entry cache configuration.
+ *
+ * @remarks
+ * Defaults to `{ maxEntries: 100, ttlMs: 300_000 }`. Set to `false` to disable caching.
+ */
+ cache?: false | ContentfulEntryCacheOptions
+}
+
+/**
+ * Options for {@link CoreBase.fetchOptimizedEntry}.
+ *
+ * @public
+ */
+export interface FetchOptimizedEntryOptions {
+ /** Per-call Contentful `getEntry()` query overrides. */
+ query?: ContentfulEntryQuery
+ /** Selected optimizations used for personalized entry resolution. */
+ selectedOptimizations?: SelectedOptimizationArray
+}
+
+/**
+ * Result returned by {@link CoreBase.fetchOptimizedEntry}.
+ *
+ * @public
+ */
+export interface FetchOptimizedEntryResult<
+ S extends EntrySkeletonType = EntrySkeletonType,
+ M extends ChainModifiers = undefined,
+ L extends LocaleCode = LocaleCode,
+> extends ResolvedData {
+ /** Baseline entry fetched from Contentful before optimization resolution. */
+ baselineEntry: Entry
+}
+
+type ResolvedContentfulEntryCacheOptions = readonly [maxEntries: number, ttlMs: number]
+type ContentfulEntryCacheRecord = readonly [expiresAt: number, promise: Promise]
+
+function createContentfulEntryQuery(
+ defaultQuery: ContentfulEntryQuery | undefined,
+ query: ContentfulEntryQuery | undefined,
+ locale: string | undefined,
+): ContentfulEntryQuery {
+ const mergedQuery: ContentfulEntryQuery = {
+ ...defaultQuery,
+ ...query,
+ }
+
+ mergedQuery.include ??= DEFAULT_CONTENTFUL_ENTRY_INCLUDE
+
+ if (mergedQuery.locale === undefined && locale !== undefined) {
+ mergedQuery.locale = locale
+ }
+
+ return mergedQuery
+}
+
+function evictEntryCache(cache: Map, maxEntries: number): void {
+ while (cache.size > maxEntries) {
+ const oldestKey = cache.keys().next()
+ if (oldestKey.done) break
+ cache.delete(oldestKey.value)
+ }
+}
+
/**
* Lifecycle container for event and state interceptors.
*
@@ -50,6 +163,15 @@ export interface CoreConfig extends Pick {
}
private resolvedLocale: string | undefined
+ private readonly entryCache = new Map()
+ private readonly entryCacheOptions: ResolvedContentfulEntryCacheOptions | undefined
/** Current SDK locale for Experience API requests and event context. */
get locale(): string | undefined {
@@ -103,6 +227,14 @@ abstract class CoreBase {
constructor(config: TConfig, api: CoreBaseApiClientConfig = {}, locale?: string) {
this.config = config
this.resolvedLocale = locale
+ const cache = config.contentful?.cache
+ this.entryCacheOptions =
+ cache === false
+ ? undefined
+ : [
+ cache?.maxEntries ?? DEFAULT_CONTENTFUL_ENTRY_CACHE_MAX_ENTRIES,
+ cache?.ttlMs ?? DEFAULT_CONTENTFUL_ENTRY_CACHE_TTL_MS,
+ ]
const { eventBuilder, logLevel, environment, clientId, fetchOptions } = config
@@ -133,6 +265,110 @@ abstract class CoreBase {
this.resolvedLocale = locale
}
+ /**
+ * Clear SDK-managed Contentful entry cache entries for this SDK instance.
+ *
+ * @public
+ */
+ clearContentfulEntryCache(): void {
+ this.entryCache.clear()
+ }
+
+ /**
+ * Fetch a Contentful entry through the configured `contentful.js` client.
+ *
+ * @param entryId - Contentful entry ID to fetch.
+ * @param query - Per-call `getEntry()` query overrides.
+ * @returns The Contentful entry returned by the configured client.
+ *
+ * @remarks
+ * The SDK merges `contentful.defaultQuery`, the per-call query, SDK locale fallback, and
+ * `include: 10` before calling `getEntry()`. By default, results are cached per SDK instance.
+ */
+ async fetchContentfulEntry<
+ S extends EntrySkeletonType = EntrySkeletonType,
+ L extends LocaleCode = LocaleCode,
+ >(entryId: string, query?: ContentfulEntryQuery): Promise>
+ async fetchContentfulEntry(entryId: string, query?: ContentfulEntryQuery): Promise {
+ const { client } = this.config.contentful ?? {}
+
+ if (!client) {
+ throw new Error('fetchContentfulEntry() requires contentful.client in SDK config.')
+ }
+
+ const { locale } = this
+ const mergedQuery = createContentfulEntryQuery(
+ this.config.contentful?.defaultQuery,
+ query,
+ locale,
+ )
+ const { entryCacheOptions: cacheOptions } = this
+
+ if (cacheOptions === undefined) {
+ return await client.getEntry(entryId, mergedQuery)
+ }
+
+ const now = Date.now()
+ const cacheKey = `${entryId}:${JSON.stringify(mergedQuery)}`
+ const cached = this.entryCache.get(cacheKey)
+
+ if (cached && cached[0] > now) {
+ this.entryCache.delete(cacheKey)
+ this.entryCache.set(cacheKey, cached)
+ return await cached[1]
+ }
+
+ if (cached) {
+ this.entryCache.delete(cacheKey)
+ }
+
+ const promise = client.getEntry(entryId, mergedQuery)
+ const cacheRecord: ContentfulEntryCacheRecord = [now + cacheOptions[1], promise]
+
+ this.entryCache.set(cacheKey, cacheRecord)
+ evictEntryCache(this.entryCache, cacheOptions[0])
+
+ try {
+ return await promise
+ } catch (error) {
+ if (this.entryCache.get(cacheKey) === cacheRecord) {
+ this.entryCache.delete(cacheKey)
+ }
+
+ throw error
+ }
+ }
+
+ /**
+ * Fetch a Contentful entry and resolve its optimized variant.
+ *
+ * @param entryId - Contentful entry ID to fetch.
+ * @param options - Per-call Contentful query and selected optimizations.
+ * @returns Baseline entry, resolved entry, and optimization metadata.
+ *
+ * @remarks
+ * This is additive to the synchronous `resolveOptimizedEntry()` API.
+ */
+ async fetchOptimizedEntry<
+ S extends EntrySkeletonType = EntrySkeletonType,
+ L extends LocaleCode = LocaleCode,
+ >(
+ entryId: string,
+ options?: FetchOptimizedEntryOptions,
+ ): Promise>
+ async fetchOptimizedEntry(
+ entryId: string,
+ options: FetchOptimizedEntryOptions = {},
+ ): Promise> {
+ const baselineEntry = await this.fetchContentfulEntry(entryId, options.query)
+ const resolvedData = this.resolveOptimizedEntry(baselineEntry, options.selectedOptimizations)
+
+ return {
+ baselineEntry,
+ ...resolvedData,
+ }
+ }
+
/**
* Get the value of a custom flag derived from a set of optimization changes.
*
diff --git a/packages/universal/core-sdk/src/CoreStateful.test.ts b/packages/universal/core-sdk/src/CoreStateful.test.ts
index aef90b68b..97eb6a596 100644
--- a/packages/universal/core-sdk/src/CoreStateful.test.ts
+++ b/packages/universal/core-sdk/src/CoreStateful.test.ts
@@ -1,6 +1,8 @@
-import CoreStateful, { type CoreStatefulConfig } from './CoreStateful'
+import type { Entry } from 'contentful'
import type { ChangeArray } from './api-schemas'
import { getPreviewPanelBridge, hydrateOptimizationData } from './bridge-support'
+import type { ContentfulEntryClient, ContentfulEntryQuery } from './CoreBase'
+import CoreStateful, { type CoreStatefulConfig } from './CoreStateful'
import type {
BlockedEvent,
EventOptimizationContext,
@@ -20,6 +22,8 @@ const config: CoreStatefulConfig = {
environment: 'main',
}
+type MockContentfulGetEntry = (entryId: string, query?: ContentfulEntryQuery) => Promise
+
const DARK_MODE_CHANGE: ChangeArray[number] = {
key: 'dark-mode',
type: 'Variable',
@@ -542,6 +546,29 @@ describe('CoreStateful blocked event handling', () => {
)
})
+ it('defaults fetchOptimizedEntry to the selectedOptimizations signal', async () => {
+ const getEntry = rs.fn(
+ async () => await Promise.resolve(optimizedEntry),
+ )
+ const client: ContentfulEntryClient & {
+ readonly getEntry: ReturnType>
+ } = {
+ getEntry,
+ }
+ const core = createCoreStateful({
+ contentful: { client, cache: false },
+ })
+
+ signals.selectedOptimizations.value = selectedOptimizationsFixture
+
+ const result = await core.fetchOptimizedEntry('entry-id')
+
+ expect(getEntry).toHaveBeenCalledWith('entry-id', { include: 10 })
+ expect(result.baselineEntry).toBe(optimizedEntry)
+ expect(result.entry.sys.id).toBe('4k6ZyFQnR2POY5IJLLlJRb')
+ expect(result.optimizationContextId).toEqual(expect.any(String))
+ })
+
it('registers unique optimization contexts and clears them on reset and destroy', () => {
const core = createCoreStatefulHarness()
diff --git a/packages/universal/core-sdk/src/CoreStateless.test.ts b/packages/universal/core-sdk/src/CoreStateless.test.ts
index d39edf178..70584d4ec 100644
--- a/packages/universal/core-sdk/src/CoreStateless.test.ts
+++ b/packages/universal/core-sdk/src/CoreStateless.test.ts
@@ -1,6 +1,10 @@
+import type { Entry, EntryFieldTypes, EntrySkeletonType } from 'contentful'
import type { OptimizationData } from './api-schemas'
+import type { ContentfulEntryClient, ContentfulEntryQuery } from './CoreBase'
import CoreStateless from './CoreStateless'
import type { BlockedEvent } from './events'
+import { optimizedEntry } from './test/fixtures/optimizedEntry'
+import { selectedOptimizations } from './test/fixtures/selectedOptimizations'
const TRACK_CLICK_PROFILE_ERROR =
'CoreStatelessRequest.trackClick() requires a request-bound profile id for Insights delivery.'
@@ -11,6 +15,14 @@ const TRACK_FLAG_VIEW_PROFILE_ERROR =
const NON_STICKY_TRACK_VIEW_PROFILE_ERROR =
'CoreStatelessRequest.trackView() requires a request-bound profile id when `payload.sticky` is not `true`.'
+type ProductEntrySkeleton = EntrySkeletonType<
+ {
+ title: EntryFieldTypes.Symbol
+ },
+ 'product'
+>
+type MockContentfulGetEntry = (entryId: string, query?: ContentfulEntryQuery) => Promise
+
const EMPTY_OPTIMIZATION_DATA: OptimizationData = {
changes: [],
selectedOptimizations: [],
@@ -39,6 +51,19 @@ const EMPTY_OPTIMIZATION_DATA: OptimizationData = {
},
}
+function createOptimizedEntryClient(): ContentfulEntryClient & {
+ readonly getEntry: ReturnType>
+} {
+ const getEntry = rs.fn(async () => await Promise.resolve(optimizedEntry))
+ const client: ContentfulEntryClient & {
+ readonly getEntry: ReturnType>
+ } = {
+ getEntry,
+ }
+
+ return client
+}
+
async function invokeUntypedMethod(
requestOptimization: ReturnType,
method: 'trackClick' | 'trackHover' | 'trackFlagView' | 'trackView',
@@ -240,6 +265,111 @@ describe('CoreStateless', () => {
)
})
+ it('uses latest request-bound Experience selections for managed optimized entry fetching', async () => {
+ const client = createOptimizedEntryClient()
+ const core = new CoreStateless({
+ clientId: 'key_123',
+ environment: 'main',
+ contentful: { client, cache: false },
+ })
+ rs.spyOn(core.api.experience, 'upsertProfile').mockResolvedValue({
+ ...EMPTY_OPTIMIZATION_DATA,
+ selectedOptimizations,
+ })
+ const requestOptimization = core.forRequest({
+ consent: true,
+ profile: { id: 'profile-123' },
+ })
+
+ await requestOptimization.page()
+ const result = await requestOptimization.fetchOptimizedEntry('entry-id')
+
+ expect(result.entry.sys.id).toBe('4k6ZyFQnR2POY5IJLLlJRb')
+ expect(result.selectedOptimization).toEqual(
+ expect.objectContaining({
+ experienceId: '2qVK4T5lnScbswoyBuGipd',
+ variantIndex: 1,
+ }),
+ )
+ })
+
+ it('preserves managed Contentful entry skeleton types for request-bound fetches', async () => {
+ const client = createOptimizedEntryClient()
+ const core = new CoreStateless({
+ clientId: 'key_123',
+ environment: 'main',
+ contentful: { client, cache: false },
+ })
+ const requestOptimization = core.forRequest({ consent: true })
+
+ const entry = await requestOptimization.fetchContentfulEntry('entry-id')
+ const result = await requestOptimization.fetchOptimizedEntry('entry-id')
+ const typedEntry: Entry = entry
+ const typedBaselineEntry: Entry = result.baselineEntry
+ const typedResolvedEntry: Entry = result.entry
+ const typedTitle: string = result.entry.fields.title
+
+ expect(typedEntry).toBe(entry)
+ expect(typedBaselineEntry).toBe(result.baselineEntry)
+ expect(typedResolvedEntry).toBe(result.entry)
+ expect(typedTitle).toBe(result.entry.fields.title)
+ })
+
+ it('uses request locale as the managed Contentful query fallback', async () => {
+ const client = createOptimizedEntryClient()
+ const core = new CoreStateless({
+ clientId: 'key_123',
+ environment: 'main',
+ locale: 'en-US',
+ contentful: {
+ client,
+ defaultQuery: { include: 2 },
+ cache: false,
+ },
+ })
+ const requestOptimization = core.forRequest({
+ consent: true,
+ locale: 'de-DE',
+ })
+
+ await requestOptimization.fetchContentfulEntry('entry-id')
+ await requestOptimization.fetchOptimizedEntry('entry-id', {
+ query: { locale: 'fr-FR' },
+ selectedOptimizations: [],
+ })
+
+ expect(client.getEntry).toHaveBeenNthCalledWith(1, 'entry-id', {
+ include: 2,
+ locale: 'de-DE',
+ })
+ expect(client.getEntry).toHaveBeenNthCalledWith(2, 'entry-id', {
+ include: 2,
+ locale: 'fr-FR',
+ })
+
+ const defaultLocaleClient = createOptimizedEntryClient()
+ const defaultLocaleCore = new CoreStateless({
+ clientId: 'key_123',
+ environment: 'main',
+ contentful: {
+ client: defaultLocaleClient,
+ defaultQuery: { locale: 'it-IT' },
+ cache: false,
+ },
+ })
+ const defaultLocaleRequest = defaultLocaleCore.forRequest({
+ consent: true,
+ locale: 'de-DE',
+ })
+
+ await defaultLocaleRequest.fetchContentfulEntry('entry-id')
+
+ expect(defaultLocaleClient.getEntry).toHaveBeenCalledWith('entry-id', {
+ include: 10,
+ locale: 'it-IT',
+ })
+ })
+
it('defaults allowedEventTypes to empty for stateless core requests', async () => {
const blockedEvents: BlockedEvent[] = []
const core = new CoreStateless({
diff --git a/packages/universal/core-sdk/src/CoreStatelessRequest.ts b/packages/universal/core-sdk/src/CoreStatelessRequest.ts
index f9a214c6c..0968fbaa2 100644
--- a/packages/universal/core-sdk/src/CoreStatelessRequest.ts
+++ b/packages/universal/core-sdk/src/CoreStatelessRequest.ts
@@ -7,6 +7,12 @@ import {
type InsightsEvent as InsightsEventPayload,
} from '@contentful/optimization-api-client/api-schemas'
import { createScopedLogger } from '@contentful/optimization-api-client/logger'
+import type { Entry, EntrySkeletonType, LocaleCode } from 'contentful'
+import type {
+ ContentfulEntryQuery,
+ FetchOptimizedEntryOptions,
+ FetchOptimizedEntryResult,
+} from './CoreBase'
import type CoreStateless from './CoreStateless'
import type { CoreStatelessInsightsOptions, CoreStatelessRequestOptions } from './CoreStateless'
import { PartialProfile, type OptimizationData } from './api-schemas'
@@ -60,7 +66,10 @@ export type CoreStatelessRequestConsent =
export interface CoreStatelessForRequestOptions {
/** Request-scoped event and persistence consent. */
consent: CoreStatelessRequestConsent
- /** Request-scoped SDK locale used for Experience API requests and default event context. */
+ /**
+ * Request-scoped SDK locale used for Experience API requests, default event context, and
+ * SDK-managed Contentful entry fetching when no Contentful query locale is configured.
+ */
locale?: string
/** Profile already known for the request, such as an anonymous ID from a cookie. */
profile?: PartialProfile
@@ -141,10 +150,12 @@ type RequestExperienceMethod = 'identify' | 'page' | 'screen' | 'track'
export class CoreStatelessRequest {
private readonly core: CoreStateless
private currentProfile: PartialProfile | undefined
+ private currentSelectedOptimizations: OptimizationData['selectedOptimizations'] | undefined
private readonly requestEventConsent: boolean | undefined
private readonly eventContext: UniversalEventBuilderArgs
private readonly experienceOptions: CoreStatelessRequestOptions | undefined
private readonly insightsOptions: CoreStatelessInsightsOptions | undefined
+ private readonly requestLocale: string | undefined
readonly canPersistProfile: boolean
constructor(core: CoreStateless, options: CoreStatelessForRequestOptions) {
@@ -154,6 +165,7 @@ export class CoreStatelessRequest {
this.core = core
this.currentProfile = profile
+ this.requestLocale = requestLocale
this.requestEventConsent = isBooleanConsent ? consent : consent.events
this.canPersistProfile = (isBooleanConsent ? consent : consent.persistence) === true
this.eventContext =
@@ -280,6 +292,64 @@ export class CoreStatelessRequest {
)
}
+ /**
+ * Fetch a Contentful entry through the parent SDK's configured `contentful.js` client.
+ *
+ * @remarks
+ * If `contentful.defaultQuery` and the per-call query omit `locale`, this request's `locale`
+ * becomes the managed Contentful query locale.
+ *
+ * @public
+ */
+ async fetchContentfulEntry<
+ S extends EntrySkeletonType = EntrySkeletonType,
+ L extends LocaleCode = LocaleCode,
+ >(entryId: string, query?: ContentfulEntryQuery): Promise> {
+ return await this.core.fetchContentfulEntry(
+ entryId,
+ this.withRequestContentfulLocale(query),
+ )
+ }
+
+ /**
+ * Fetch a Contentful entry and resolve it with request-local selected optimizations.
+ *
+ * @remarks
+ * If `options.selectedOptimizations` is omitted, this uses the latest selected optimizations
+ * returned by an accepted request-bound Experience API call. If `contentful.defaultQuery` and the
+ * per-call query omit `locale`, this request's `locale` becomes the managed Contentful query
+ * locale.
+ *
+ * @public
+ */
+ async fetchOptimizedEntry<
+ S extends EntrySkeletonType = EntrySkeletonType,
+ L extends LocaleCode = LocaleCode,
+ >(
+ entryId: string,
+ options: FetchOptimizedEntryOptions = {},
+ ): Promise> {
+ return await this.core.fetchOptimizedEntry(entryId, {
+ ...options,
+ query: this.withRequestContentfulLocale(options.query),
+ selectedOptimizations: options.selectedOptimizations ?? this.currentSelectedOptimizations,
+ })
+ }
+
+ private withRequestContentfulLocale(
+ query: ContentfulEntryQuery | undefined,
+ ): ContentfulEntryQuery | undefined {
+ if (
+ this.requestLocale === undefined ||
+ query?.locale !== undefined ||
+ this.core.config.contentful?.defaultQuery?.locale !== undefined
+ ) {
+ return query
+ }
+
+ return { ...query, locale: this.requestLocale }
+ }
+
private withEventContext(
payload: TPayload,
): TPayload {
@@ -326,8 +396,9 @@ export class CoreStatelessRequest {
this.experienceOptions,
)
- const { profile } = result
+ const { profile, selectedOptimizations } = result
this.currentProfile = profile
+ this.currentSelectedOptimizations = selectedOptimizations
return result
}
diff --git a/packages/universal/core-sdk/src/OptimizedEntryMetadata.ts b/packages/universal/core-sdk/src/OptimizedEntryMetadata.ts
new file mode 100644
index 000000000..1fbeb1f8d
--- /dev/null
+++ b/packages/universal/core-sdk/src/OptimizedEntryMetadata.ts
@@ -0,0 +1,31 @@
+import type { SelectedOptimizationArray } from '@contentful/optimization-api-client/api-schemas'
+import type { ChainModifiers, Entry, EntrySkeletonType, LocaleCode } from 'contentful'
+import type { ResolvedData } from './resolvers'
+
+/**
+ * Baseline and resolved-entry metadata for optimized-entry render surfaces.
+ *
+ * @public
+ */
+export interface OptimizedEntryMetadata<
+ S extends EntrySkeletonType = EntrySkeletonType,
+ M extends ChainModifiers = ChainModifiers,
+ L extends LocaleCode = LocaleCode,
+> {
+ /** Entry supplied by the caller or fetched before optimization resolution. */
+ readonly baselineEntry: Entry
+ /** ID of the baseline entry supplied by the caller or fetched before optimization resolution. */
+ readonly baselineEntryId: string
+ /** Baseline or resolved variant entry. */
+ readonly entry: Entry
+ /** ID of the baseline or resolved variant entry. */
+ readonly entryId: string
+ /** Opaque runtime-owned optimization context ID for entry interaction tracking. */
+ readonly optimizationContextId: string | undefined
+ /** Full resolved entry payload returned by the optimization resolver. */
+ readonly resolvedData: ResolvedData
+ /** Selected optimization metadata, if a matching optimization was selected. */
+ readonly selectedOptimization: ResolvedData['selectedOptimization']
+ /** Selected optimizations used to resolve this entry, when available. */
+ readonly selectedOptimizations: SelectedOptimizationArray | undefined
+}
diff --git a/packages/universal/core-sdk/src/OptimizedEntrySourceController.test.ts b/packages/universal/core-sdk/src/OptimizedEntrySourceController.test.ts
new file mode 100644
index 000000000..f195260c1
--- /dev/null
+++ b/packages/universal/core-sdk/src/OptimizedEntrySourceController.test.ts
@@ -0,0 +1,168 @@
+import type { Entry } from 'contentful'
+import type { ContentfulEntryQuery } from './CoreBase'
+import { OptimizedEntrySourceController } from './OptimizedEntrySourceController'
+
+function createTestEntry(id: string): Entry {
+ return {
+ fields: { title: id },
+ metadata: { tags: [] },
+ sys: {
+ contentType: { sys: { id: 'test-content-type', linkType: 'ContentType', type: 'Link' } },
+ createdAt: '2024-01-01T00:00:00.000Z',
+ environment: { sys: { id: 'main', linkType: 'Environment', type: 'Link' } },
+ id,
+ publishedVersion: 1,
+ revision: 1,
+ space: { sys: { id: 'space-id', linkType: 'Space', type: 'Link' } },
+ type: 'Entry',
+ updatedAt: '2024-01-01T00:00:00.000Z',
+ },
+ }
+}
+
+function createDeferred(): {
+ readonly promise: Promise
+ readonly reject: (reason?: unknown) => void
+ readonly resolve: (value: T) => void
+} {
+ let resolveDeferred: (value: T) => void = () => undefined
+ let rejectDeferred: (reason?: unknown) => void = () => undefined
+ const promise = new Promise((resolve, reject) => {
+ resolveDeferred = resolve
+ rejectDeferred = reject
+ })
+
+ return { promise, reject: rejectDeferred, resolve: resolveDeferred }
+}
+
+async function flushMicrotasks(): Promise {
+ await Promise.resolve()
+ await Promise.resolve()
+}
+
+function createSdk(
+ fetchContentfulEntry: (entryId: string, query?: ContentfulEntryQuery) => Promise,
+): {
+ readonly fetchContentfulEntry: (entryId: string, query?: ContentfulEntryQuery) => Promise
+} {
+ return { fetchContentfulEntry: rs.fn(fetchContentfulEntry) }
+}
+
+describe('OptimizedEntrySourceController', () => {
+ it('lets baselineEntry take precedence over entryId without fetching', () => {
+ const baselineEntry = createTestEntry('baseline')
+ const sdk = createSdk(async () => await Promise.resolve(createTestEntry('managed')))
+ const controller = new OptimizedEntrySourceController()
+
+ controller.updateOptions({
+ baselineEntry,
+ entryId: 'managed',
+ sdk,
+ isSdkStateReady: true,
+ })
+
+ expect(controller.getSnapshot()).toEqual({
+ baselineEntry,
+ isLoading: false,
+ })
+ expect(sdk.fetchContentfulEntry).not.toHaveBeenCalled()
+ })
+
+ it('fetches managed entryId entries with query options', async () => {
+ const baselineEntry = createTestEntry('baseline')
+ const sdk = createSdk(async () => await Promise.resolve(baselineEntry))
+ const controller = new OptimizedEntrySourceController()
+
+ controller.updateOptions({
+ entryId: 'baseline',
+ entryQuery: { locale: 'de-DE' },
+ sdk,
+ isSdkStateReady: true,
+ })
+
+ expect(controller.getSnapshot()).toEqual({
+ entryId: 'baseline',
+ isLoading: true,
+ })
+ await flushMicrotasks()
+
+ expect(sdk.fetchContentfulEntry).toHaveBeenCalledWith('baseline', { locale: 'de-DE' })
+ expect(controller.getSnapshot()).toEqual({
+ baselineEntry,
+ entryId: 'baseline',
+ isLoading: false,
+ })
+ })
+
+ it('stays loading without fetching until the SDK is ready', () => {
+ const sdk = createSdk(async () => await Promise.resolve(createTestEntry('baseline')))
+ const controller = new OptimizedEntrySourceController()
+
+ controller.updateOptions({ entryId: 'baseline' })
+
+ expect(controller.getSnapshot()).toEqual({
+ entryId: 'baseline',
+ isLoading: true,
+ })
+ expect(sdk.fetchContentfulEntry).not.toHaveBeenCalled()
+
+ controller.updateOptions({ entryId: 'baseline', sdk, isSdkStateReady: false })
+
+ expect(controller.getSnapshot()).toEqual({
+ entryId: 'baseline',
+ isLoading: true,
+ })
+ expect(sdk.fetchContentfulEntry).not.toHaveBeenCalled()
+ })
+
+ it('ignores stale fetches after source changes or disconnects', async () => {
+ const firstEntry = createTestEntry('first')
+ const secondEntry = createTestEntry('second')
+ const thirdEntry = createTestEntry('third')
+ const firstFetch = createDeferred()
+ const secondFetch = createDeferred()
+ const thirdFetch = createDeferred()
+ const sdk = createSdk(async (entryId) => {
+ if (entryId === 'first') return await firstFetch.promise
+ if (entryId === 'second') return await secondFetch.promise
+ return await thirdFetch.promise
+ })
+ const controller = new OptimizedEntrySourceController()
+
+ controller.updateOptions({ entryId: 'first', sdk, isSdkStateReady: true })
+ controller.updateOptions({ entryId: 'second', sdk, isSdkStateReady: true })
+
+ secondFetch.resolve(secondEntry)
+ await flushMicrotasks()
+ expect(controller.getSnapshot().baselineEntry).toBe(secondEntry)
+
+ firstFetch.resolve(firstEntry)
+ await flushMicrotasks()
+ expect(controller.getSnapshot().baselineEntry).toBe(secondEntry)
+
+ controller.updateOptions({ entryId: 'third', sdk, isSdkStateReady: true })
+ controller.disconnect()
+ thirdFetch.resolve(thirdEntry)
+ await flushMicrotasks()
+
+ expect(controller.getSnapshot()).toEqual({
+ entryId: 'third',
+ isLoading: true,
+ })
+ })
+
+ it('surfaces failed fetches as Error snapshots', async () => {
+ const sdk = createSdk(async () => await Promise.reject(new Error('CDA failed')))
+ const controller = new OptimizedEntrySourceController()
+
+ controller.updateOptions({ entryId: 'baseline', sdk, isSdkStateReady: true })
+ await flushMicrotasks()
+
+ expect(controller.getSnapshot().error).toBeInstanceOf(Error)
+ expect(controller.getSnapshot().error?.message).toBe('CDA failed')
+ expect(controller.getSnapshot()).toMatchObject({
+ entryId: 'baseline',
+ isLoading: false,
+ })
+ })
+})
diff --git a/packages/universal/core-sdk/src/OptimizedEntrySourceController.ts b/packages/universal/core-sdk/src/OptimizedEntrySourceController.ts
new file mode 100644
index 000000000..3d426c2c6
--- /dev/null
+++ b/packages/universal/core-sdk/src/OptimizedEntrySourceController.ts
@@ -0,0 +1,189 @@
+import type { Entry } from 'contentful'
+import type { ContentfulEntryQuery } from './CoreBase'
+
+export interface OptimizedEntrySourceControllerOptions {
+ /** Baseline entry supplied by the application. Takes precedence over `entryId`. */
+ readonly baselineEntry?: Entry
+ /** Contentful entry ID fetched through the SDK-managed Contentful client. */
+ readonly entryId?: string
+ /** Per-entry Contentful `getEntry()` query overrides. */
+ readonly entryQuery?: ContentfulEntryQuery
+ /** SDK surface required for managed entry fetching. */
+ readonly sdk?: {
+ fetchContentfulEntry: (entryId: string, query?: ContentfulEntryQuery) => Promise
+ }
+ /** Whether SDK state is ready for managed entry fetching. */
+ readonly isSdkStateReady?: boolean
+}
+
+export interface OptimizedEntrySourceSnapshot {
+ /** Current baseline entry, either supplied directly or fetched by `entryId`. */
+ readonly baselineEntry?: Entry
+ /** Managed entry ID currently being fetched or resolved. */
+ readonly entryId?: string
+ /** Managed fetch error, normalized to `Error`. */
+ readonly error?: Error
+ /** Whether a managed entry source is waiting for SDK readiness or fetch resolution. */
+ readonly isLoading: boolean
+}
+
+export type OptimizedEntrySourceSnapshotListener = (snapshot: OptimizedEntrySourceSnapshot) => void
+
+type OptimizedEntrySourceSdk = NonNullable
+
+const LOADING_ENTRY_CONTENT_TYPE_ID = 'contentful-loading-entry'
+const LOADING_ENTRY_TIMESTAMP = '2024-01-01T00:00:00Z'
+const REQUEST_IDLE = 0
+const REQUEST_LOADING = 1
+const REQUEST_SUCCESS = 2
+const REQUEST_ERROR = REQUEST_SUCCESS + REQUEST_LOADING
+
+function toError(error: unknown): Error {
+ return error instanceof Error ? error : new Error(String(error))
+}
+
+function getFetchKey(entryId: string, query: ContentfulEntryQuery | undefined): string {
+ return `${entryId}:${JSON.stringify(query ?? {})}`
+}
+
+function areSourceSnapshotsEqual(
+ left: OptimizedEntrySourceSnapshot,
+ right: OptimizedEntrySourceSnapshot,
+): boolean {
+ return (
+ left.baselineEntry === right.baselineEntry &&
+ left.entryId === right.entryId &&
+ left.error === right.error &&
+ left.isLoading === right.isLoading
+ )
+}
+
+/** Creates a stable placeholder entry while framework wrappers wait for a managed fetch. */
+export function createOptimizedEntryLoadingEntry(entryId: string): Entry {
+ return {
+ fields: {},
+ metadata: { tags: [] },
+ sys: {
+ contentType: {
+ sys: { id: LOADING_ENTRY_CONTENT_TYPE_ID, linkType: 'ContentType', type: 'Link' },
+ },
+ createdAt: LOADING_ENTRY_TIMESTAMP,
+ environment: { sys: { id: '', linkType: 'Environment', type: 'Link' } },
+ id: entryId,
+ publishedVersion: 0,
+ revision: 0,
+ space: { sys: { id: '', linkType: 'Space', type: 'Link' } },
+ type: 'Entry',
+ updatedAt: LOADING_ENTRY_TIMESTAMP,
+ },
+ }
+}
+
+/** Coordinates direct baseline entries and SDK-managed `entryId` fetching for framework wrappers. */
+export class OptimizedEntrySourceController {
+ private key: string | undefined
+ private sdk: OptimizedEntrySourceSdk | undefined
+ private version = 0
+ private listener: OptimizedEntrySourceSnapshotListener | undefined
+ private status = REQUEST_IDLE
+ private snapshot: OptimizedEntrySourceSnapshot = { isLoading: false }
+
+ updateOptions(options: OptimizedEntrySourceControllerOptions): void {
+ if (options.baselineEntry !== undefined) {
+ this.resetSource({ baselineEntry: options.baselineEntry, isLoading: false })
+ return
+ }
+
+ const { entryId } = options
+ if (entryId === undefined) {
+ this.resetSource({ isLoading: false })
+ return
+ }
+
+ const key = getFetchKey(entryId, options.entryQuery)
+ const { sdk } = options
+ const sourceChanged = this.key !== key || this.sdk !== sdk
+
+ if (sourceChanged) {
+ this.version += 1
+ this.key = key
+ this.sdk = sdk
+ this.status = REQUEST_IDLE
+ }
+
+ if (sdk === undefined || options.isSdkStateReady !== true) {
+ if (this.status !== REQUEST_IDLE) {
+ this.version += 1
+ this.status = REQUEST_IDLE
+ }
+ this.setSnapshot({ entryId, isLoading: true })
+ return
+ }
+
+ if (this.status !== REQUEST_IDLE) {
+ return
+ }
+
+ const version = ++this.version
+ this.status = REQUEST_LOADING
+ this.setSnapshot({ entryId, isLoading: true }, true)
+
+ void sdk.fetchContentfulEntry(entryId, options.entryQuery).then(
+ (entry) => {
+ if (!this.isCurrent(version, key, sdk)) return
+
+ this.status = REQUEST_SUCCESS
+ this.setSnapshot({ baselineEntry: entry, entryId, isLoading: false })
+ },
+ (error: unknown) => {
+ if (!this.isCurrent(version, key, sdk)) return
+
+ this.status = REQUEST_ERROR
+ this.setSnapshot({ entryId, error: toError(error), isLoading: false })
+ },
+ )
+ }
+
+ private resetSource(snapshot: OptimizedEntrySourceSnapshot): void {
+ if (this.key !== undefined || this.status !== REQUEST_IDLE) {
+ this.version += 1
+ }
+ this.key = undefined
+ this.sdk = undefined
+ this.status = REQUEST_IDLE
+ this.setSnapshot(snapshot)
+ }
+
+ private isCurrent(version: number, key: string, sdk: OptimizedEntrySourceSdk): boolean {
+ return (
+ this.version === version &&
+ this.key === key &&
+ this.sdk === sdk &&
+ this.status === REQUEST_LOADING
+ )
+ }
+
+ getSnapshot(): OptimizedEntrySourceSnapshot {
+ return this.snapshot
+ }
+
+ setSnapshotListener(listener: OptimizedEntrySourceSnapshotListener | undefined): void {
+ this.listener = listener
+ }
+
+ disconnect(): void {
+ this.version += 1
+ if (this.status === REQUEST_LOADING) {
+ this.status = REQUEST_IDLE
+ }
+ }
+
+ private setSnapshot(snapshot: OptimizedEntrySourceSnapshot, forceNotify = false): void {
+ if (!forceNotify && areSourceSnapshotsEqual(this.snapshot, snapshot)) {
+ return
+ }
+
+ this.snapshot = snapshot
+ this.listener?.(snapshot)
+ }
+}
diff --git a/packages/universal/core-sdk/src/entry-source.ts b/packages/universal/core-sdk/src/entry-source.ts
new file mode 100644
index 000000000..b1b8efe97
--- /dev/null
+++ b/packages/universal/core-sdk/src/entry-source.ts
@@ -0,0 +1,8 @@
+export type { ContentfulEntryQuery } from './CoreBase'
+export {
+ OptimizedEntrySourceController,
+ createOptimizedEntryLoadingEntry,
+ type OptimizedEntrySourceControllerOptions,
+ type OptimizedEntrySourceSnapshot,
+ type OptimizedEntrySourceSnapshotListener,
+} from './OptimizedEntrySourceController'
diff --git a/packages/universal/core-sdk/src/index.ts b/packages/universal/core-sdk/src/index.ts
index bc4f7120f..ea9f4571e 100644
--- a/packages/universal/core-sdk/src/index.ts
+++ b/packages/universal/core-sdk/src/index.ts
@@ -36,6 +36,7 @@ export type {
QueueFlushRecoveredContext,
} from './lib/queue'
export * from './locale'
+export type * from './OptimizedEntryMetadata'
export * from './page-context'
export type { ExperienceQueue } from './queues/ExperienceQueue'
export type { InsightsQueue } from './queues/InsightsQueue'
diff --git a/packages/web/frameworks/nextjs-sdk/README.md b/packages/web/frameworks/nextjs-sdk/README.md
index 3d07717c6..7d85d1ad7 100644
--- a/packages/web/frameworks/nextjs-sdk/README.md
+++ b/packages/web/frameworks/nextjs-sdk/README.md
@@ -37,11 +37,12 @@ SDK on the server with the React Web SDK on the client; it is not a new optimiza
## Install
```sh
-pnpm add @contentful/optimization-nextjs
+pnpm add @contentful/optimization-nextjs contentful
```
Next.js, React, and React DOM are application-owned peer dependencies. The adapter uses the runtime
-already installed by your app instead of installing its own copy.
+already installed by your app instead of installing its own copy. The `contentful` package is the
+app-owned CDA client used by the managed entry fetching example.
## Server setup
@@ -52,35 +53,44 @@ import {
getNextjsServerOptimizationData,
} from '@contentful/optimization-nextjs/server'
import { NextjsOptimizationState } from '@contentful/optimization-nextjs/client'
+import { createClient } from 'contentful'
import { cookies, headers } from 'next/headers'
+const contentfulClient = createClient({
+ accessToken: process.env.CONTENTFUL_ACCESS_TOKEN!,
+ space: process.env.CONTENTFUL_SPACE_ID!,
+})
+
const sdk = createNextjsOptimization({
clientId: 'client-id',
+ contentful: { client: contentfulClient },
environment: 'main',
+ locale: 'en-US',
})
export default async function Page() {
const [cookieStore, headerStore] = await Promise.all([cookies(), headers()])
- const { data } = await getNextjsServerOptimizationData(sdk, {
+ const { data, requestOptimization } = await getNextjsServerOptimizationData(sdk, {
consent: { events: true, persistence: true },
cookies: cookieStore,
headers: headerStore,
locale: 'en-US',
})
- const resolvedData = sdk.resolveOptimizedEntry(entry, data?.selectedOptimizations)
+ const result = await requestOptimization.fetchOptimizedEntry('hero-entry')
return (
<>
-
- {resolvedData.entry.fields.title}
-
+ {result.entry.fields.title}
>
)
}
```
+`ServerOptimizedEntry` also keeps the manual `baselineEntry` plus `resolvedData` props for apps that
+fetch Contentful entries outside the SDK.
+
`NextjsOptimizationState` must render under SDK context. That context can come from
`OptimizationRoot` or `OptimizationProvider`, commonly mounted in a shared App Router layout.
diff --git a/packages/web/frameworks/nextjs-sdk/src/server.test.tsx b/packages/web/frameworks/nextjs-sdk/src/server.test.tsx
index 6f90b8ee5..e56af95ef 100644
--- a/packages/web/frameworks/nextjs-sdk/src/server.test.tsx
+++ b/packages/web/frameworks/nextjs-sdk/src/server.test.tsx
@@ -435,4 +435,23 @@ describe('Next.js server helpers', () => {
className: 'entry',
})
})
+
+ it('renders a server wrapper from a managed fetch result', () => {
+ const element = ServerOptimizedEntry({
+ as: 'article',
+ children: 'Rendered content',
+ result: {
+ baselineEntry,
+ ...resolvedData,
+ },
+ })
+
+ expect(element.props).toMatchObject({
+ 'data-ctfl-baseline-id': 'baseline-entry',
+ 'data-ctfl-entry-id': 'variant-entry',
+ 'data-ctfl-optimization-id': 'experience-id',
+ 'data-ctfl-variant-index': 1,
+ children: 'Rendered content',
+ })
+ })
})
diff --git a/packages/web/frameworks/nextjs-sdk/src/server.tsx b/packages/web/frameworks/nextjs-sdk/src/server.tsx
index db48a9e3d..dcf23be11 100644
--- a/packages/web/frameworks/nextjs-sdk/src/server.tsx
+++ b/packages/web/frameworks/nextjs-sdk/src/server.tsx
@@ -30,6 +30,7 @@ export type {
CoreStatelessRequest,
CoreStatelessRequestConsent,
CoreStatelessRequestOptions,
+ FetchOptimizedEntryResult,
PageViewBuilderArgs,
ResolvedData,
UniversalEventBuilderArgs,
@@ -144,13 +145,26 @@ export type NextjsPageContextInput =
| NonNullable
| CreateNextjsPageContextOptions
+export type ServerOptimizedEntryFetchResult = ServerTrackingResolvedData & {
+ readonly baselineEntry: ServerTrackingBaselineEntry
+}
+
type ServerOptimizedEntryOwnProps =
ServerTrackingAttributeOptions & {
readonly as?: TElement
- readonly baselineEntry: ServerTrackingBaselineEntry
readonly children?: ReactNode
- readonly resolvedData: ServerTrackingResolvedData
- }
+ } & (
+ | {
+ readonly baselineEntry: ServerTrackingBaselineEntry
+ readonly resolvedData: ServerTrackingResolvedData
+ readonly result?: never
+ }
+ | {
+ readonly baselineEntry?: never
+ readonly resolvedData?: never
+ readonly result: ServerOptimizedEntryFetchResult
+ }
+ )
type DataCtflAttributeName = `data-ctfl-${string}`
@@ -340,20 +354,39 @@ export function persistNextjsAnonymousId(
}
}
-export function ServerOptimizedEntry({
- as,
- baselineEntry,
- children,
- clickable,
- hoverDurationUpdateIntervalMs,
- resolvedData,
- trackClicks,
- trackHovers,
- trackViews,
- viewDurationUpdateIntervalMs,
- ...htmlProps
-}: ServerOptimizedEntryProps): ReactElement {
+function getServerOptimizedEntryData(
+ props: ServerOptimizedEntryProps,
+): {
+ readonly baselineEntry: ServerTrackingBaselineEntry
+ readonly resolvedData: ServerTrackingResolvedData
+} {
+ if (props.result !== undefined) {
+ return { baselineEntry: props.result.baselineEntry, resolvedData: props.result }
+ }
+
+ return { baselineEntry: props.baselineEntry, resolvedData: props.resolvedData }
+}
+
+export function ServerOptimizedEntry(
+ props: ServerOptimizedEntryProps,
+): ReactElement {
+ const {
+ as,
+ baselineEntry: _baselineEntry,
+ children,
+ clickable,
+ hoverDurationUpdateIntervalMs,
+ resolvedData: _resolvedData,
+ result: _result,
+ trackClicks,
+ trackHovers,
+ trackViews,
+ viewDurationUpdateIntervalMs,
+ ...htmlProps
+ } = props
const Element = as ?? 'div'
+ const { baselineEntry, resolvedData } = getServerOptimizedEntryData(props)
+
const trackingAttributes: ServerTrackingAttributes = getServerTrackingAttributes(
baselineEntry,
resolvedData,
@@ -367,7 +400,11 @@ export function ServerOptimizedEntry), ...trackingAttributes },
+ children,
+ )
}
function mergePageContext(
diff --git a/packages/web/frameworks/react-web-sdk/README.md b/packages/web/frameworks/react-web-sdk/README.md
index 6dac7505e..f23fb8352 100644
--- a/packages/web/frameworks/react-web-sdk/README.md
+++ b/packages/web/frameworks/react-web-sdk/README.md
@@ -60,6 +60,9 @@ Install using an NPM-compatible package manager, pnpm for example:
pnpm install @contentful/optimization-react-web
```
+Add `contentful` too when `OptimizationRoot` will use your app-owned `contentful.js` client for
+managed entry fetching.
+
React and React DOM are application-owned peer dependencies. The SDK uses the React runtime already
installed by your app instead of installing its own copy.
@@ -105,6 +108,7 @@ such as `liveUpdates`, `onStatesReady`, and `serverOptimizationState`. The Web S
| `environment` | No | `'main'` | Contentful environment identifier |
| `api` | No | Web SDK defaults | Experience API and Insights API endpoint and request options |
| `app` | No | `undefined` | Application metadata attached to outgoing event context |
+| `contentful` | No | `undefined` | App-owned `contentful.js` client, default query, and cache |
| `locale` | No | `undefined` | SDK Experience API and default event locale |
| `defaults` | No | `undefined` | Configuration/default state such as consent or persistence consent |
| `serverOptimizationState` | No | `undefined` | Server-returned Optimization state to apply before provider children mount |
@@ -254,10 +258,10 @@ function HeroEntry({ baselineEntry }) {
}
```
-Fetch Contentful entries in the app layer with one CDA locale. For localized apps, configure your
-application locale and pass it directly before passing entries to `OptimizedEntry`,
-`useEntryResolver()`, or `useOptimizedEntry()`. Do not pass all-locale CDA responses from
-`withAllLocales` or `locale=*`; these APIs expect direct single-locale field values. See
+For manual entries, fetch Contentful entries in the app layer with one CDA locale before passing
+them to `baselineEntry` surfaces. For managed fetching, use `entryId` and `entryQuery`. Do not pass
+all-locale CDA responses from `withAllLocales` or `locale=*`; these APIs expect direct single-locale
+field values. See
[Entry optimization and variant resolution](https://contentful.github.io/optimization/documents/Documentation.Concepts.Entry_personalization_and_variant_resolution.html#single-locale-cda-entry-contract)
for the entry contract and
[Locale handling in the Optimization SDK Suite](https://contentful.github.io/optimization/documents/Documentation.Concepts.Locale_handling_in_the_Optimization_SDK_Suite.html)
@@ -314,8 +318,9 @@ provider.
### OptimizedEntry
-`OptimizedEntry` resolves a baseline Contentful entry and renders either the selected variant or the
-baseline entry:
+`OptimizedEntry` fetches a Contentful entry by ID when the root SDK is configured with
+`contentful: { client }`, then renders the selected variant or baseline. `baselineEntry` remains
+supported for the manual path:
```tsx
import { OptimizedEntry } from '@contentful/optimization-react-web'
@@ -329,6 +334,47 @@ function HeroEntry({ baselineEntry }) {
}
```
+When `OptimizationRoot` uses a Web SDK configured with `contentful: { client }`, React entry
+surfaces can fetch by entry ID:
+
+```tsx
+function HeroEntry() {
+ return (
+ }
+ errorFallback={() => }
+ >
+ {(resolvedEntry) => }
+
+ )
+}
+```
+
+Hooks use the same managed entry source:
+
+```tsx
+import { useOptimizedEntry } from '@contentful/optimization-react-web'
+
+function HeroEntry() {
+ const { entry, error, isLoading } = useOptimizedEntry({
+ entryId: 'hero-entry',
+ entryQuery: { locale: 'en-US' },
+ })
+
+ if (isLoading) return
+ if (error || !entry) return
+ return
+}
+```
+
+`baselineEntry` remains supported and takes the manual path. Use `onEntryError` when application
+code needs to log or report managed CDA failures. Use `errorFallback` on `OptimizedEntry` to render
+fallback UI for managed CDA failures. Use `onEntryResolved`, the render prop metadata, or the hook's
+`metadata` and `isResolved` fields when application code needs the baseline ID, resolved entry ID,
+or optimization context after tracking attributes are ready.
+
Use `loadingFallback`, direct children, wrapper props, and nested composition patterns when needed.
For optimized entries, the loading phase begins immediately while optimization is unresolved. If the
state is still unresolved after 5 seconds, the component reveals baseline content so loading does
diff --git a/packages/web/frameworks/react-web-sdk/package.json b/packages/web/frameworks/react-web-sdk/package.json
index 69ded0bbe..463abf1b4 100644
--- a/packages/web/frameworks/react-web-sdk/package.json
+++ b/packages/web/frameworks/react-web-sdk/package.json
@@ -121,8 +121,8 @@
"buildTools": {
"bundleSize": {
"gzipBudgets": {
- "index.cjs": 4000,
- "index.mjs": 3500
+ "index.cjs": 4300,
+ "index.mjs": 3700
}
}
},
diff --git a/packages/web/frameworks/react-web-sdk/src/index.ts b/packages/web/frameworks/react-web-sdk/src/index.ts
index 5cfee6a7f..a510e1b46 100644
--- a/packages/web/frameworks/react-web-sdk/src/index.ts
+++ b/packages/web/frameworks/react-web-sdk/src/index.ts
@@ -31,6 +31,7 @@ export {
} from './hooks/useOptimizationState'
export { OptimizedEntry } from './optimized-entry/OptimizedEntry'
export type {
+ OptimizedEntryErrorFallback,
OptimizedEntryLoadingFallback,
OptimizedEntryProps,
} from './optimized-entry/OptimizedEntry'
diff --git a/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.test.tsx b/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.test.tsx
index b86bfb675..4e12dc3ee 100644
--- a/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.test.tsx
+++ b/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.test.tsx
@@ -14,6 +14,21 @@ import {
type TestEntry,
} from './OptimizedEntry.testUtils'
+function createDeferred(): {
+ readonly promise: Promise
+ readonly reject: (reason?: unknown) => void
+ readonly resolve: (value: T) => void
+} {
+ let resolveDeferred: (value: T) => void = () => undefined
+ let rejectDeferred: (reason?: unknown) => void = () => undefined
+ const promise = new Promise((resolve, reject) => {
+ resolveDeferred = resolve
+ rejectDeferred = reject
+ })
+
+ return { promise, reject: rejectDeferred, resolve: resolveDeferred }
+}
+
describe('OptimizedEntry', () => {
const baseline = makeEntry('baseline')
const optimizedBaseline = makeOptimizableEntry('optimized-baseline')
@@ -155,6 +170,62 @@ describe('OptimizedEntry', () => {
await view.unmount()
})
+ it('fetches entryId entries and renders the loading fallback until they resolve', async () => {
+ const deferred = createDeferred()
+ const { optimization } = createRuntime((entry) => ({ entry }))
+ const fetchContentfulEntry = rs.fn(async () => await deferred.promise)
+ Reflect.set(optimization, 'fetchContentfulEntry', fetchContentfulEntry)
+
+ const view = await renderComponent(
+
+ {(resolved) => readTitle(resolved)}
+ ,
+ optimization,
+ )
+
+ expect(view.container.textContent).toContain('loading')
+ expect(fetchContentfulEntry).toHaveBeenCalledWith('baseline', { locale: 'de-DE' })
+
+ await act(async () => {
+ deferred.resolve(baseline)
+ await deferred.promise
+ })
+
+ expect(view.container.textContent).toContain('baseline')
+ expect(getWrapper(view.container).dataset.ctflEntryId).toBe('baseline')
+
+ await view.unmount()
+ })
+
+ it('renders entryId fetch error fallbacks', async () => {
+ const deferred = createDeferred()
+ const error = new Error('CDA failed')
+ const onEntryError = rs.fn()
+ const { optimization } = createRuntime((entry) => ({ entry }))
+ Reflect.set(optimization, 'fetchContentfulEntry', async () => await deferred.promise)
+
+ const view = await renderComponent(
+ `error: ${entryError.message}`}
+ onEntryError={onEntryError}
+ >
+ {(resolved) => readTitle(resolved)}
+ ,
+ optimization,
+ )
+
+ await act(async () => {
+ deferred.reject(error)
+ await deferred.promise.catch(() => undefined)
+ })
+
+ expect(onEntryError).toHaveBeenCalledWith(error)
+ expect(view.container.textContent).toContain('error: CDA failed')
+
+ await view.unmount()
+ })
+
it('reveals baseline after the unresolved loading timeout when a custom fallback is provided', async () => {
rs.useFakeTimers()
@@ -245,6 +316,42 @@ describe('OptimizedEntry', () => {
await view.unmount()
})
+ it('passes resolved metadata to render props and onEntryResolved', async () => {
+ const onEntryResolved = rs.fn()
+ const { optimization, emit } = createRuntime((entry, selectedOptimizations) => {
+ if (!selectedOptimizations?.length) return { entry }
+ return {
+ entry: variantA,
+ optimizationContextId: 'ctx-1',
+ selectedOptimization: selectedOptimizations[0],
+ }
+ })
+
+ const view = await renderComponent(
+
+ {(resolved, metadata) =>
+ `${readTitle(resolved)}:${metadata?.baselineEntryId}:${metadata?.entryId}:${metadata?.optimizationContextId}`
+ }
+ ,
+ optimization,
+ )
+
+ await emit(variantOneState)
+
+ expect(view.container.textContent).toContain('variant-a:optimized-baseline:variant-a:ctx-1')
+ expect(onEntryResolved).toHaveBeenCalledWith(
+ expect.objectContaining({
+ baselineEntry: optimizedBaseline,
+ baselineEntryId: 'optimized-baseline',
+ entry: variantA,
+ entryId: 'variant-a',
+ optimizationContextId: 'ctx-1',
+ }),
+ )
+
+ await view.unmount()
+ })
+
it('maps configurable Web SDK attributes to data attributes', async () => {
const { optimization } = createRuntime((entry) => ({ entry }))
diff --git a/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.tsx b/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.tsx
index 29a049b92..9b9c757fe 100644
--- a/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.tsx
+++ b/packages/web/frameworks/react-web-sdk/src/optimized-entry/OptimizedEntry.tsx
@@ -1,22 +1,42 @@
+import type {
+ ContentfulEntryQuery,
+ OptimizedEntryMetadata,
+} from '@contentful/optimization-web/core-sdk'
import {
OPTIMIZED_ENTRY_HOST_DISPLAY,
+ createOptimizedEntryLoadingEntry,
resolveOptimizedEntryNestingState,
} from '@contentful/optimization-web/presentation'
import type { Entry } from 'contentful'
-import { createContext, useContext, useEffect, useMemo, useRef, type JSX } from 'react'
+import {
+ createContext,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ type JSX,
+ type ReactNode,
+} from 'react'
import { createScopedLogger } from '../logger'
import {
resolveChildren,
+ resolveErrorFallback,
resolveLoadingFallback,
resolveLoadingLayoutTargetStyle,
+ type ErrorFallback,
type LoadingFallback,
type OptimizedEntryChildren,
type RenderProp,
type WrapperElement,
} from './optimizedEntryUtils'
-import { useOptimizedEntrySnapshot } from './useOptimizedEntry'
+import {
+ useManagedBaselineEntry,
+ useOptimizedEntrySnapshot,
+ type UseOptimizedEntryParams,
+} from './useOptimizedEntry'
export type OptimizedEntryLoadingFallback = LoadingFallback
+export type OptimizedEntryErrorFallback = ErrorFallback
export type OptimizedEntryWrapperElement = WrapperElement
export type OptimizedEntryRenderProp = RenderProp
@@ -25,14 +45,9 @@ export type OptimizedEntryRenderProp = RenderProp
*
* @public
*/
-export interface OptimizedEntryProps {
+interface OptimizedEntrySharedProps {
/**
- * The baseline Contentful entry fetched with `include: 10`.
- * Must include `nt_experiences` field with linked optimization data.
- */
- baselineEntry: Entry
- /**
- * Render prop that receives the resolved variant entry.
+ * Render prop that receives the resolved variant entry and metadata when resolved.
*/
children: OptimizedEntryChildren
/**
@@ -57,6 +72,18 @@ export interface OptimizedEntryProps {
* Optional fallback rendered while optimization state is unresolved.
*/
loadingFallback?: OptimizedEntryLoadingFallback
+ /**
+ * Optional fallback rendered when SDK-managed entry fetching fails.
+ */
+ errorFallback?: OptimizedEntryErrorFallback
+ /**
+ * Callback invoked when SDK-managed entry fetching fails.
+ */
+ onEntryError?: (error: Error) => void
+ /**
+ * Callback invoked when a resolved entry is rendered with tracking attributes ready.
+ */
+ onEntryResolved?: (metadata: OptimizedEntryMetadata) => void
/**
* Marks the optimized entry wrapper as a click target for entry click tracking.
*/
@@ -83,10 +110,106 @@ export interface OptimizedEntryProps {
hoverDurationUpdateIntervalMs?: number
}
+type OptimizedEntrySourceProps =
+ | {
+ /**
+ * The baseline Contentful entry fetched with `include: 10`.
+ * Must include `nt_experiences` field with linked optimization data.
+ */
+ baselineEntry: Entry
+ entryId?: never
+ entryQuery?: never
+ }
+ | {
+ baselineEntry?: never
+ /** Contentful entry ID fetched through the SDK-managed Contentful client. */
+ entryId: string
+ /** Per-call Contentful `getEntry()` query overrides. */
+ entryQuery?: ContentfulEntryQuery
+ }
+
+/**
+ * Props for the {@link OptimizedEntry} component.
+ *
+ * @public
+ */
+export type OptimizedEntryProps = OptimizedEntrySharedProps & OptimizedEntrySourceProps
+
const WRAPPER_STYLE = Object.freeze({ display: OPTIMIZED_ENTRY_HOST_DISPLAY })
const OptimizedEntryNestingContext = createContext | null>(null)
const logger = createScopedLogger('React:OptimizedEntry')
+interface OptimizedEntryFrameProps {
+ readonly children: ReactNode
+ readonly currentAndAncestorBaselineIds: ReadonlySet
+ readonly dataTestId: string | undefined
+ readonly hostAttributes?: Record
+ readonly Wrapper: OptimizedEntryWrapperElement
+}
+
+function OptimizedEntryFrame({
+ children,
+ currentAndAncestorBaselineIds,
+ dataTestId,
+ hostAttributes,
+ Wrapper,
+}: OptimizedEntryFrameProps): JSX.Element {
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+function hasBaselineEntry(
+ entryProps: OptimizedEntrySourceProps,
+): entryProps is Extract {
+ return entryProps.baselineEntry !== undefined
+}
+
+function resolveEntrySource(
+ entryProps: OptimizedEntrySourceProps,
+ liveUpdates: boolean | undefined,
+ onEntryError: ((error: Error) => void) | undefined,
+): { readonly entryId: string | undefined; readonly managedEntryParams: UseOptimizedEntryParams } {
+ if (hasBaselineEntry(entryProps)) {
+ return {
+ entryId: undefined,
+ managedEntryParams: { baselineEntry: entryProps.baselineEntry, liveUpdates, onEntryError },
+ }
+ }
+
+ return {
+ entryId: entryProps.entryId,
+ managedEntryParams: {
+ entryId: entryProps.entryId,
+ entryQuery: entryProps.entryQuery,
+ liveUpdates,
+ onEntryError,
+ },
+ }
+}
+
+function renderErrorFallback(
+ errorFallback: OptimizedEntryErrorFallback | undefined,
+ error: Error,
+ frameProps: Omit,
+): JSX.Element | null {
+ const errorContent = resolveErrorFallback(errorFallback, error)
+
+ if (errorContent === undefined) {
+ return null
+ }
+
+ return {errorContent}
+}
+
+function getTargetDisplay(wrapper: OptimizedEntryWrapperElement): 'block' | 'inline' {
+ return wrapper === 'span' ? 'inline' : 'block'
+}
+
function useDuplicateBaselineGuard(baselineEntryId: string): {
currentAndAncestorBaselineIds: ReadonlySet
hasDuplicateBaselineAncestor: boolean
@@ -116,27 +239,37 @@ function useDuplicateBaselineGuard(baselineEntryId: string): {
}
export function OptimizedEntry({
- baselineEntry,
children,
liveUpdates,
as = 'div',
testId,
'data-testid': dataTestIdProp,
loadingFallback,
+ errorFallback,
+ onEntryError,
+ onEntryResolved,
clickable,
hoverDurationUpdateIntervalMs,
trackClicks,
trackHovers,
trackViews,
viewDurationUpdateIntervalMs,
+ ...entryProps
}: OptimizedEntryProps): JSX.Element | null {
+ const { entryId, managedEntryParams } = resolveEntrySource(entryProps, liveUpdates, onEntryError)
+ const managedEntry = useManagedBaselineEntry(managedEntryParams)
+ const loadingEntry = useMemo(
+ () => createOptimizedEntryLoadingEntry(entryId ?? 'contentful-entry'),
+ [entryId],
+ )
+ const baselineEntry = managedEntry.entry ?? loadingEntry
const {
sys: { id: baselineEntryId },
} = baselineEntry
const { currentAndAncestorBaselineIds, hasDuplicateBaselineAncestor } =
useDuplicateBaselineGuard(baselineEntryId)
const hasCustomLoadingFallback = loadingFallback !== undefined
- const targetDisplay = as === 'span' ? 'inline' : 'block'
+ const targetDisplay = getTargetDisplay(as)
const snapshot = useOptimizedEntrySnapshot({
baselineEntry,
clickable,
@@ -149,14 +282,52 @@ export function OptimizedEntry({
trackViews,
viewDurationUpdateIntervalMs,
})
+ const { metadata } = snapshot
+
+ useEffect(() => {
+ if (managedEntry.entry && !hasDuplicateBaselineAncestor && snapshot.isResolved) {
+ onEntryResolved?.(metadata)
+ }
+ }, [
+ hasDuplicateBaselineAncestor,
+ managedEntry.entry,
+ metadata,
+ onEntryResolved,
+ snapshot.isResolved,
+ ])
if (hasDuplicateBaselineAncestor) {
return null
}
+ const dataTestId = dataTestIdProp ?? testId
+ const Wrapper = as
+ const frameProps = {
+ currentAndAncestorBaselineIds,
+ dataTestId,
+ Wrapper,
+ }
+
+ if (managedEntry.error) {
+ return renderErrorFallback(errorFallback, managedEntry.error, frameProps)
+ }
+
const resolvedLoadingFallback = hasCustomLoadingFallback
? resolveLoadingFallback(loadingFallback)
: undefined
+
+ if (!managedEntry.entry) {
+ const loadingLayoutTargetStyle = resolveLoadingLayoutTargetStyle(targetDisplay, false)
+
+ return (
+
+
+ {resolvedLoadingFallback}
+
+
+ )
+ }
+
const { entry, hostAttributes, loadingPresentation } = snapshot
const {
hideLoadingLayoutTarget,
@@ -167,8 +338,6 @@ export function OptimizedEntry({
const loadingContent = shouldRenderBaselineWhileLoading
? resolveChildren(children, baselineEntry)
: resolvedLoadingFallback
- const dataTestId = dataTestIdProp ?? testId
- const Wrapper = as
if (showLoadingFallback) {
const LoadingLayoutTarget = Wrapper
@@ -178,25 +347,21 @@ export function OptimizedEntry({
)
return (
-
-
-
- {loadingContent}
-
-
-
+
+
+ {loadingContent}
+
+
)
}
return (
-
-
- {resolveChildren(children, entry)}
-
-
+
+ {resolveChildren(children, entry, metadata)}
+
)
}
diff --git a/packages/web/frameworks/react-web-sdk/src/optimized-entry/optimizedEntryUtils.ts b/packages/web/frameworks/react-web-sdk/src/optimized-entry/optimizedEntryUtils.ts
index 1771548aa..c313249a3 100644
--- a/packages/web/frameworks/react-web-sdk/src/optimized-entry/optimizedEntryUtils.ts
+++ b/packages/web/frameworks/react-web-sdk/src/optimized-entry/optimizedEntryUtils.ts
@@ -1,10 +1,14 @@
-import type { OptimizedEntryLoadingTargetDisplay } from '@contentful/optimization-web/presentation'
+import type {
+ OptimizedEntryLoadingTargetDisplay,
+ OptimizedEntryMetadata,
+} from '@contentful/optimization-web/presentation'
import type { Entry } from 'contentful'
import type { CSSProperties, ReactNode } from 'react'
export type LoadingFallback = ReactNode | (() => ReactNode)
+export type ErrorFallback = ReactNode | ((error: Error) => ReactNode)
export type WrapperElement = 'div' | 'span'
-export type RenderProp = (resolvedEntry: Entry) => ReactNode
+export type RenderProp = (resolvedEntry: Entry, metadata?: OptimizedEntryMetadata) => ReactNode
export type OptimizedEntryChildren = ReactNode | RenderProp
export type LoadingLayoutTargetStyle = Pick
@@ -17,16 +21,27 @@ export function resolveLoadingFallback(loadingFallback: LoadingFallback | undefi
return loadingFallback
}
-export function isRenderProp(children: OptimizedEntryChildren): children is RenderProp {
- return typeof children === 'function'
+export function resolveErrorFallback(
+ errorFallback: ErrorFallback | undefined,
+ error: Error,
+): ReactNode {
+ if (typeof errorFallback === 'function') {
+ return errorFallback(error)
+ }
+
+ return errorFallback
}
-export function resolveChildren(children: OptimizedEntryChildren, entry: Entry): ReactNode {
- if (!isRenderProp(children)) {
+export function resolveChildren(
+ children: OptimizedEntryChildren,
+ entry: Entry,
+ metadata?: OptimizedEntryMetadata,
+): ReactNode {
+ if (typeof children !== 'function') {
return children
}
- return children(entry)
+ return children(entry, metadata)
}
export function resolveLoadingLayoutTargetStyle(
diff --git a/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.test.tsx b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.test.tsx
index 2e009fdd2..24ceac75c 100644
--- a/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.test.tsx
+++ b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.test.tsx
@@ -1,7 +1,9 @@
import type { SelectedOptimizationArray } from '@contentful/optimization-web/api-schemas'
+import { act, useState } from 'react'
import type { LiveUpdatesContextValue } from '../context/LiveUpdatesContext'
import type { OptimizationSdk } from '../context/OptimizationContext'
import {
+ createOptimizationSdk,
createRuntime,
defaultLiveUpdatesContext,
createTestEntry as makeEntry,
@@ -76,6 +78,7 @@ describe('useOptimizedEntry', () => {
]
const { emit, optimization } = createRuntime((entry, selectedOptimizations) => ({
entry: selectedOptimizations ? variantEntry : entry,
+ optimizationContextId: selectedOptimizations ? 'ctx-1' : undefined,
selectedOptimization: selectedOptimizations?.[0],
}))
const rendered = await renderHook({ baselineEntry, optimization })
@@ -86,6 +89,16 @@ describe('useOptimizedEntry', () => {
entry: variantEntry,
selectedOptimization: variantState[0],
isLoading: false,
+ isResolved: true,
+ metadata: {
+ baselineEntry,
+ baselineEntryId: 'baseline',
+ entry: variantEntry,
+ entryId: 'variant-a',
+ optimizationContextId: 'ctx-1',
+ selectedOptimization: variantState[0],
+ selectedOptimizations: variantState,
+ },
canOptimize: true,
selectedOptimizations: variantState,
})
@@ -191,4 +204,100 @@ describe('useOptimizedEntry', () => {
await rendered.unmount()
})
+
+ it('returns updated baselineEntry props during the first render after manual entry changes', async () => {
+ const firstEntry = makeEntry('baseline')
+ const secondEntry = makeEntry('updated-baseline')
+ const optimization = createOptimizationSdk()
+ const renderedEntryIdsAfterUpdate: string[] = []
+ let setBaselineEntry: ((entry: typeof firstEntry) => void) | undefined
+
+ function Probe(): null {
+ const [baselineEntry, setEntry] = useState(firstEntry)
+ setBaselineEntry = setEntry
+ const result = useOptimizedEntry({ baselineEntry })
+ if (baselineEntry === secondEntry) {
+ renderedEntryIdsAfterUpdate.push(result.baselineEntry.sys.id)
+ }
+ return null
+ }
+
+ const view = await renderWithOptimizationProviders(, optimization)
+
+ await act(async () => {
+ setBaselineEntry?.(secondEntry)
+ await Promise.resolve()
+ })
+
+ expect(renderedEntryIdsAfterUpdate[0]).toBe('updated-baseline')
+
+ await view.unmount()
+ })
+
+ it('fetches entryId entries through the SDK', async () => {
+ const baselineEntry = makeEntry('baseline')
+ const fetchContentfulEntry = rs.fn(async () => await Promise.resolve(baselineEntry))
+ const optimization = createOptimizationSdk({
+ fetchContentfulEntry,
+ })
+ let captured: UseOptimizedEntryResult | undefined = undefined
+
+ function Probe(): null {
+ captured = useOptimizedEntry({
+ entryId: 'baseline',
+ entryQuery: { locale: 'de-DE' },
+ })
+ return null
+ }
+
+ function getCaptured(): UseOptimizedEntryResult {
+ if (!captured) throw new Error('Expected hook result to be captured')
+ return captured
+ }
+
+ const view = await renderWithOptimizationProviders(, optimization)
+ await act(async () => {
+ await Promise.resolve()
+ await Promise.resolve()
+ })
+
+ expect(fetchContentfulEntry).toHaveBeenCalledWith('baseline', { locale: 'de-DE' })
+ expect(getCaptured().entry).toBe(baselineEntry)
+ expect(getCaptured().baselineEntry).toBe(baselineEntry)
+ expect(getCaptured().error).toBeUndefined()
+
+ await view.unmount()
+ })
+
+ it('surfaces entryId fetch errors', async () => {
+ const error = new Error('CDA failed')
+ const onEntryError = rs.fn()
+ const optimization = createOptimizationSdk({
+ fetchContentfulEntry: async () => await Promise.reject(error),
+ })
+ let captured: UseOptimizedEntryResult | undefined = undefined
+
+ function Probe(): null {
+ captured = useOptimizedEntry({ entryId: 'baseline', onEntryError })
+ return null
+ }
+
+ function getCaptured(): UseOptimizedEntryResult {
+ if (!captured) throw new Error('Expected hook result to be captured')
+ return captured
+ }
+
+ const view = await renderWithOptimizationProviders(, optimization)
+ await act(async () => {
+ await Promise.resolve()
+ await Promise.resolve()
+ })
+
+ expect(onEntryError).toHaveBeenCalledWith(error)
+ expect(getCaptured().entry).toBeUndefined()
+ expect(getCaptured().error).toBe(error)
+ expect(getCaptured().isLoading).toBe(false)
+
+ await view.unmount()
+ })
})
diff --git a/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.ts b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.ts
index 988354e7d..5599c00ec 100644
--- a/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.ts
+++ b/packages/web/frameworks/react-web-sdk/src/optimized-entry/useOptimizedEntry.ts
@@ -1,33 +1,63 @@
import type { SelectedOptimizationArray } from '@contentful/optimization-web/api-schemas'
-import type { ResolvedData } from '@contentful/optimization-web/core-sdk'
+import type { ContentfulEntryQuery, ResolvedData } from '@contentful/optimization-web/core-sdk'
import {
+ createOptimizedEntryLoadingEntry,
OptimizedEntryController,
+ OptimizedEntrySourceController,
+ type OptimizedEntryMetadata,
type OptimizedEntrySnapshot,
+ type OptimizedEntrySourceSnapshot,
} from '@contentful/optimization-web/presentation'
import type { Entry, EntrySkeletonType } from 'contentful'
-import { useEffect, useMemo, useState } from 'react'
+import { useEffect, useMemo, useRef, useState } from 'react'
import { useLiveUpdates } from '../hooks/useLiveUpdates'
import { useOptimizationContext } from '../hooks/useOptimization'
-export interface UseOptimizedEntryParams {
- baselineEntry: Entry
+export type UseOptimizedEntryParams = {
liveUpdates?: boolean
+ onEntryError?: (error: Error) => void
+} & (
+ | {
+ baselineEntry: Entry
+ entryId?: never
+ entryQuery?: never
+ }
+ | {
+ baselineEntry?: never
+ entryId: string
+ entryQuery?: ContentfulEntryQuery
+ }
+)
+
+interface UseManagedBaselineEntryResult {
+ readonly entry: Entry | undefined
+ readonly error: Error | undefined
+ readonly isLoading: boolean
}
-export interface UseOptimizedEntryResult {
+type UseOptimizedEntryBaselineParams = Extract
+type UseOptimizedEntryManagedParams = Extract
+
+export interface UseOptimizedEntryResult {
canOptimize: boolean
- entry: Entry
+ entry: TEntry
+ baselineEntry: TEntry
+ error: Error | undefined
isLoading: boolean
isReady: boolean
+ isResolved: boolean
+ metadata: OptimizedEntryMetadata | undefined
selectedOptimization: ResolvedData['selectedOptimization']
resolvedData: ResolvedData
selectedOptimizations: SelectedOptimizationArray | undefined
}
-export interface UseOptimizedEntrySnapshotParams extends UseOptimizedEntryParams {
+export interface UseOptimizedEntrySnapshotParams {
+ baselineEntry: Entry
clickable?: boolean
hasCustomLoadingFallback?: boolean
hoverDurationUpdateIntervalMs?: number
+ liveUpdates?: boolean
targetDisplay?: 'block' | 'inline'
trackClicks?: boolean
trackHovers?: boolean
@@ -35,6 +65,73 @@ export interface UseOptimizedEntrySnapshotParams extends UseOptimizedEntryParams
viewDurationUpdateIntervalMs?: number
}
+function getEntryQueryKey(query: ContentfulEntryQuery | undefined): string {
+ return JSON.stringify(query ?? {})
+}
+
+export function useManagedBaselineEntry({
+ baselineEntry,
+ entryId,
+ entryQuery,
+ onEntryError,
+}: UseOptimizedEntryParams): UseManagedBaselineEntryResult {
+ const { sdk, isReady } = useOptimizationContext()
+ const entryQueryKey = getEntryQueryKey(entryQuery)
+ const [controller] = useState(() => new OptimizedEntrySourceController())
+ const [snapshot, setSnapshot] = useState(() => {
+ if (baselineEntry !== undefined) {
+ return { baselineEntry, isLoading: false }
+ }
+
+ return { entryId, isLoading: true }
+ })
+ const reportedErrorRef = useRef(undefined)
+
+ useEffect(() => {
+ controller.setSnapshotListener(setSnapshot)
+
+ return () => {
+ controller.setSnapshotListener(undefined)
+ controller.disconnect()
+ }
+ }, [controller])
+
+ useEffect(() => {
+ controller.updateOptions({
+ baselineEntry,
+ entryId,
+ entryQuery,
+ sdk,
+ isSdkStateReady: isReady,
+ })
+ }, [baselineEntry, controller, entryId, entryQuery, entryQueryKey, isReady, sdk])
+
+ useEffect(() => {
+ const { error } = snapshot
+ if (error === undefined) {
+ reportedErrorRef.current = undefined
+ return
+ }
+
+ if (reportedErrorRef.current === error) {
+ return
+ }
+
+ reportedErrorRef.current = error
+ onEntryError?.(error)
+ }, [onEntryError, snapshot])
+
+ if (baselineEntry !== undefined) {
+ return { entry: baselineEntry, error: undefined, isLoading: false }
+ }
+
+ return {
+ entry: snapshot.baselineEntry,
+ error: snapshot.error,
+ isLoading: snapshot.isLoading,
+ }
+}
+
export function useOptimizedEntrySnapshot({
baselineEntry,
clickable,
@@ -113,16 +210,35 @@ export function useOptimizedEntrySnapshot({
return snapshot
}
+export function useOptimizedEntry(
+ params: UseOptimizedEntryBaselineParams,
+): UseOptimizedEntryResult
+export function useOptimizedEntry(params: UseOptimizedEntryManagedParams): UseOptimizedEntryResult
export function useOptimizedEntry(params: UseOptimizedEntryParams): UseOptimizedEntryResult {
- const snapshot = useOptimizedEntrySnapshot(params)
+ const managedEntry = useManagedBaselineEntry(params)
+ const loadingEntryId = (params as { readonly entryId?: string }).entryId ?? 'contentful-entry'
+ const loadingEntry = useMemo(
+ () => createOptimizedEntryLoadingEntry(loadingEntryId),
+ [loadingEntryId],
+ )
+ const baselineEntry = managedEntry.entry ?? loadingEntry
+ const snapshot = useOptimizedEntrySnapshot({
+ baselineEntry,
+ liveUpdates: params.liveUpdates,
+ })
+ const hasEntry = managedEntry.entry !== undefined
return {
canOptimize: snapshot.canOptimize,
- entry: snapshot.entry,
- isLoading: snapshot.isLoading,
- isReady: snapshot.isReady,
- selectedOptimization: snapshot.selectedOptimization,
+ entry: hasEntry ? snapshot.entry : undefined,
+ baselineEntry: managedEntry.entry,
+ error: managedEntry.error,
+ isLoading: managedEntry.isLoading || snapshot.isLoading,
+ isReady: hasEntry && snapshot.isReady,
+ isResolved: hasEntry && snapshot.isResolved,
+ metadata: hasEntry ? snapshot.metadata : undefined,
+ selectedOptimization: hasEntry ? snapshot.selectedOptimization : undefined,
resolvedData: snapshot.resolvedData,
- selectedOptimizations: snapshot.selectedOptimizations,
+ selectedOptimizations: hasEntry ? snapshot.selectedOptimizations : undefined,
}
}
diff --git a/packages/web/frameworks/react-web-sdk/src/test/sdkTestUtils.tsx b/packages/web/frameworks/react-web-sdk/src/test/sdkTestUtils.tsx
index 5c50853c3..03b5fe176 100644
--- a/packages/web/frameworks/react-web-sdk/src/test/sdkTestUtils.tsx
+++ b/packages/web/frameworks/react-web-sdk/src/test/sdkTestUtils.tsx
@@ -1,6 +1,7 @@
import ContentfulOptimization from '@contentful/optimization-web'
import type { SelectedOptimizationArray } from '@contentful/optimization-web/api-schemas'
import type {
+ ContentfulEntryQuery,
EventEmissionResult,
ExperienceRequestState,
ResolvedData,
@@ -42,6 +43,7 @@ export type RuntimeOptimization = OptimizationSdk
export type OptimizationSdkOverrides = Omit<
Partial,
| 'identify'
+ | 'fetchContentfulEntry'
| 'page'
| 'resolveOptimizedEntry'
| 'screen'
@@ -50,6 +52,7 @@ export type OptimizationSdkOverrides = Omit<
| 'tracking'
| 'trackView'
> & {
+ fetchContentfulEntry?: (entryId: string, query?: ContentfulEntryQuery) => Promise
identify?: EventMethodOverride
page?: EventMethodOverride
resolveOptimizedEntry?: ResolveOptimizedEntry
@@ -222,6 +225,8 @@ export function createOptimizationSdk(overrides: OptimizationSdkOverrides = {}):
flush: async () => {
await Promise.resolve()
},
+ fetchContentfulEntry: async (entryId: string) =>
+ await Promise.resolve(createTestEntry(entryId)),
getFlag: () => undefined,
getMergeTagValue: () => undefined,
hasConsent,
diff --git a/packages/web/web-sdk/README.md b/packages/web/web-sdk/README.md
index 637d6ac98..55386edb8 100644
--- a/packages/web/web-sdk/README.md
+++ b/packages/web/web-sdk/README.md
@@ -59,6 +59,9 @@ Install using an NPM-compatible package manager, pnpm for example:
pnpm install @contentful/optimization-web
```
+Add `contentful` too when the SDK will use your app-owned `contentful.js` client for managed entry
+fetching.
+
Import the Optimization class; both CJS and ESM module systems are supported, ESM preferred:
```ts
@@ -140,15 +143,22 @@ root.onStatesReady = (states) => {
entry.baselineEntry = baselineEntry
entry.addEventListener('ctfl-entry-resolved', (event) => {
- renderHero(event.detail.entry)
+ renderHero(event.detail.entry, event.detail.metadata)
})
```
-`baselineEntry` is property-only because Contentful entries are structured objects. For framework
-wrappers, assign `baselineEntry`, `sdk`, `defaults`, `api`, and callback properties after client
-hydration, then listen for `ctfl-entry-loading`, `ctfl-entry-resolved`, and `ctfl-entry-error` to
-render framework-owned UI. The custom element intentionally does not provide a framework-neutral
-render-prop API.
+`baselineEntry` is property-only because Contentful entries are structured objects. When the Web SDK
+is configured with `contentful: { client }`, `ctfl-optimized-entry` can also fetch by
+`entry-id`/`entryId`; `baselineEntry` takes precedence when both are set. Set the `entryQuery`
+property for per-entry CDA query overrides. For framework wrappers, assign `baselineEntry`,
+`entryQuery`, `sdk`, `defaults`, `api`, and callback properties after client hydration, then listen
+for `ctfl-entry-loading`, `ctfl-entry-resolved`, and `ctfl-entry-error` to render framework-owned
+UI. The custom element intentionally does not provide a framework-neutral render-prop API. Framework
+wrappers that render without the custom element but still use Web presentation helpers can import
+`OptimizedEntrySourceController` and `createOptimizedEntryLoadingEntry` from
+`@contentful/optimization-web/presentation` to share the same managed entry-source lifecycle.
+Core-only or non-Web custom runtime adapters can import those entry-source primitives directly from
+`@contentful/optimization-core/entry-source` without depending on the Web SDK.
For script-tag usage, load the main Web SDK UMD bundle and the separate Web Components UMD bundle:
@@ -187,6 +197,7 @@ the Insights API for event ingestion.
| `environment` | No | `'main'` | Contentful environment identifier |
| `api` | No | See API options below | Experience API and Insights API endpoint and request options |
| `app` | No | `undefined` | Application metadata attached to outgoing event context |
+| `contentful` | No | `undefined` | App-owned `contentful.js` client, default query, and cache |
| `locale` | No | `undefined` | SDK Experience API and default event locale |
| `defaults` | No | `undefined` | Initial state, commonly including consent, persistence consent, or profile values |
| `allowedEventTypes` | No | `['identify', 'page']` | Event types intentionally allowed while consent is unset or false |
@@ -273,8 +284,23 @@ not have data yet. Router integrations that need current-route deduplication can
### Content resolution
-Fetch Contentful entries in your application layer, then use the SDK to resolve the selected
-variant:
+When a `contentful.js` client is available, prefer SDK-managed fetching by entry ID:
+
+```ts
+const optimization = new ContentfulOptimization({
+ clientId: 'client-id',
+ contentful: { client: contentfulClient },
+ environment: 'main',
+ locale: appLocale,
+})
+
+const { baselineEntry, entry } = await optimization.fetchOptimizedEntry('hero-entry')
+```
+
+`fetchOptimizedEntry(entryId)` fetches the baseline entry and resolves it with the Web SDK's current
+`selectedOptimizations` when omitted. `fetchContentfulEntry()` only performs the managed CDA fetch.
+
+If your application already fetched the baseline entry, keep using the manual resolver:
```ts
const { accepted, data: optimizationData } = await optimization.page({
@@ -286,10 +312,10 @@ const resolvedEntry = optimization.resolveOptimizedEntry(
)
```
-Fetch entries with one CDA locale in the app layer. For localized apps, configure your application
-locale and pass it directly before calling `getEntry()` or `getEntries()`. Do not pass all-locale
-CDA responses from `withAllLocales` or `locale=*`; the resolver expects direct single-locale field
-values. See
+Use one CDA locale in either path. For localized apps, configure your application locale and pass it
+directly before calling `getEntry()`, `getEntries()`, or SDK-managed entry fetches. Do not pass
+all-locale CDA responses from `withAllLocales` or `locale=*`; the resolver expects direct
+single-locale field values. See
[Entry optimization and variant resolution](https://contentful.github.io/optimization/documents/Documentation.Concepts.Entry_personalization_and_variant_resolution.html#single-locale-cda-entry-contract)
for the entry contract and
[Locale handling in the Optimization SDK Suite](https://contentful.github.io/optimization/documents/Documentation.Concepts.Locale_handling_in_the_Optimization_SDK_Suite.html)
diff --git a/packages/web/web-sdk/src/presentation/OptimizedEntryController.test.ts b/packages/web/web-sdk/src/presentation/OptimizedEntryController.test.ts
index 7dd3e90ec..fa95ea442 100644
--- a/packages/web/web-sdk/src/presentation/OptimizedEntryController.test.ts
+++ b/packages/web/web-sdk/src/presentation/OptimizedEntryController.test.ts
@@ -108,6 +108,8 @@ function createSdk(
experienceRequestState,
optimizationPossible,
sdk: {
+ fetchContentfulEntry: async (entryId: string) =>
+ await Promise.resolve(createTestEntry(entryId)),
resolveOptimizedEntry,
states: {
canOptimize,
@@ -187,21 +189,40 @@ describe('OptimizedEntryController', () => {
controller.connect()
- expect(controller.getSnapshot().hostAttributes).toMatchObject({
- 'data-ctfl-entry-id': 'baseline',
- 'data-ctfl-track-clicks': true,
- 'data-ctfl-track-hovers': false,
- 'data-ctfl-variant-index': 0,
+ expect(controller.getSnapshot()).toMatchObject({
+ entry: baseline,
+ isResolved: true,
+ metadata: {
+ baselineEntry: baseline,
+ baselineEntryId: 'baseline',
+ entry: baseline,
+ entryId: 'baseline',
+ },
+ hostAttributes: {
+ 'data-ctfl-entry-id': 'baseline',
+ 'data-ctfl-track-clicks': true,
+ 'data-ctfl-track-hovers': false,
+ 'data-ctfl-variant-index': 0,
+ },
})
runtime.selectedOptimizations.emit(variantOneState)
- expect(controller.getSnapshot().hostAttributes).toMatchObject({
- 'data-ctfl-entry-id': 'variant-a',
- 'data-ctfl-optimization-context-id': 'ctx-1',
- 'data-ctfl-optimization-id': 'exp-hero',
- 'data-ctfl-sticky': true,
- 'data-ctfl-variant-index': 1,
+ expect(controller.getSnapshot()).toMatchObject({
+ metadata: {
+ baselineEntry: baseline,
+ entry: variantA,
+ optimizationContextId: 'ctx-1',
+ selectedOptimization: variantOneState[0],
+ selectedOptimizations: variantOneState,
+ },
+ hostAttributes: {
+ 'data-ctfl-entry-id': 'variant-a',
+ 'data-ctfl-optimization-context-id': 'ctx-1',
+ 'data-ctfl-optimization-id': 'exp-hero',
+ 'data-ctfl-sticky': true,
+ 'data-ctfl-variant-index': 1,
+ },
})
controller.disconnect()
diff --git a/packages/web/web-sdk/src/presentation/OptimizedEntryController.ts b/packages/web/web-sdk/src/presentation/OptimizedEntryController.ts
index ba3a6d1fa..b5fb6e3c7 100644
--- a/packages/web/web-sdk/src/presentation/OptimizedEntryController.ts
+++ b/packages/web/web-sdk/src/presentation/OptimizedEntryController.ts
@@ -1,4 +1,10 @@
-import type { Observable, ResolvedData, Subscription } from '@contentful/optimization-core'
+import type {
+ ContentfulEntryQuery,
+ Observable,
+ OptimizedEntryMetadata,
+ ResolvedData,
+ Subscription,
+} from '@contentful/optimization-core'
import type { SelectedOptimizationArray } from '@contentful/optimization-core/api-schemas'
import type { Entry, EntrySkeletonType } from 'contentful'
import {
@@ -27,6 +33,7 @@ export interface OptimizedEntrySdk {
entry: Entry,
selectedOptimizations?: SelectedOptimizationArray,
) => ResolvedData
+ fetchContentfulEntry: (entryId: string, query?: ContentfulEntryQuery) => Promise
}
export interface OptimizedEntrySnapshot {
@@ -35,12 +42,14 @@ export interface OptimizedEntrySnapshot {
readonly hostAttributes: OptimizedEntryTrackingAttributes
readonly isLoading: boolean
readonly isReady: boolean
+ readonly isResolved: boolean
readonly loadingPresentation: {
readonly showLoadingFallback: boolean
readonly hideLoadingLayoutTarget: boolean
readonly shouldRenderBaselineWhileLoading: boolean
readonly targetDisplay: OptimizedEntryLoadingTargetDisplay
}
+ readonly metadata: OptimizedEntryMetadata
readonly resolvedData: ResolvedData
readonly selectedOptimization: ResolvedData['selectedOptimization']
readonly selectedOptimizations: SelectedOptimizationArray | undefined
@@ -180,14 +189,35 @@ function areLoadingPresentationsEqual(
)
}
-function areSnapshotsEqual(left: OptimizedEntrySnapshot, right: OptimizedEntrySnapshot): boolean {
+function areSnapshotMetadataEqual(
+ left: OptimizedEntrySnapshot['metadata'],
+ right: OptimizedEntrySnapshot['metadata'],
+): boolean {
+ return (
+ left.baselineEntry === right.baselineEntry &&
+ left.optimizationContextId === right.optimizationContextId
+ )
+}
+
+function areSnapshotValuesEqual(
+ left: OptimizedEntrySnapshot,
+ right: OptimizedEntrySnapshot,
+): boolean {
return (
left.canOptimize === right.canOptimize &&
left.entry === right.entry &&
left.isLoading === right.isLoading &&
left.isReady === right.isReady &&
+ left.isResolved === right.isResolved &&
left.selectedOptimization === right.selectedOptimization &&
- left.selectedOptimizations === right.selectedOptimizations &&
+ left.selectedOptimizations === right.selectedOptimizations
+ )
+}
+
+function areSnapshotsEqual(left: OptimizedEntrySnapshot, right: OptimizedEntrySnapshot): boolean {
+ return (
+ areSnapshotValuesEqual(left, right) &&
+ areSnapshotMetadataEqual(left.metadata, right.metadata) &&
areLoadingPresentationsEqual(left.loadingPresentation, right.loadingPresentation) &&
areHostAttributesEqual(left.hostAttributes, right.hostAttributes)
)
@@ -367,10 +397,21 @@ export class OptimizedEntryController {
this.selectedOptimizations,
)
: createBaselineResolvedData(this.options.baselineEntry)
+ const metadata: OptimizedEntryMetadata = {
+ baselineEntry: this.options.baselineEntry,
+ baselineEntryId: this.options.baselineEntry.sys.id,
+ entry: resolvedData.entry,
+ entryId: resolvedData.entry.sys.id,
+ optimizationContextId: resolvedData.optimizationContextId,
+ resolvedData,
+ selectedOptimization: resolvedData.selectedOptimization,
+ selectedOptimizations: this.selectedOptimizations,
+ }
+ const isResolved = !showLoadingFallback
return {
canOptimize: this.canOptimize,
- entry: resolvedData.entry,
+ entry: metadata.entry,
hostAttributes: showLoadingFallback
? {}
: resolveOptimizedEntryTrackingAttributes(
@@ -380,14 +421,16 @@ export class OptimizedEntryController {
),
isLoading,
isReady: this.options.isPresentationReady,
+ isResolved,
loadingPresentation: {
showLoadingFallback,
hideLoadingLayoutTarget,
shouldRenderBaselineWhileLoading,
targetDisplay: this.options.targetDisplay,
},
+ metadata,
resolvedData,
- selectedOptimization: resolvedData.selectedOptimization,
+ selectedOptimization: metadata.selectedOptimization,
selectedOptimizations: this.selectedOptimizations,
}
}
diff --git a/packages/web/web-sdk/src/presentation/index.ts b/packages/web/web-sdk/src/presentation/index.ts
index 6d4de592c..b493a3a55 100644
--- a/packages/web/web-sdk/src/presentation/index.ts
+++ b/packages/web/web-sdk/src/presentation/index.ts
@@ -1,3 +1,11 @@
+export type { OptimizedEntryMetadata } from '@contentful/optimization-core'
+export {
+ OptimizedEntrySourceController,
+ createOptimizedEntryLoadingEntry,
+ type OptimizedEntrySourceControllerOptions,
+ type OptimizedEntrySourceSnapshot,
+ type OptimizedEntrySourceSnapshotListener,
+} from '@contentful/optimization-core/entry-source'
export {
createOptimizationRootSdkBinding,
disposeOptimizationRootSdkBinding,
diff --git a/packages/web/web-sdk/src/presentation/optimizationRootRuntime.ts b/packages/web/web-sdk/src/presentation/optimizationRootRuntime.ts
index 6fd207c50..07954d4d4 100644
--- a/packages/web/web-sdk/src/presentation/optimizationRootRuntime.ts
+++ b/packages/web/web-sdk/src/presentation/optimizationRootRuntime.ts
@@ -16,7 +16,9 @@ export type OptimizationRootSdkConfig = Omit> {
+ Partial<
+ Omit
+ > {
readonly states: OptimizedEntrySdk['states'] & {
readonly previewPanelOpen: Observable
}
diff --git a/packages/web/web-sdk/src/web-components/ContentfulOptimizedEntryElement.ts b/packages/web/web-sdk/src/web-components/ContentfulOptimizedEntryElement.ts
index 958e19c55..f79b56f30 100644
--- a/packages/web/web-sdk/src/web-components/ContentfulOptimizedEntryElement.ts
+++ b/packages/web/web-sdk/src/web-components/ContentfulOptimizedEntryElement.ts
@@ -1,5 +1,10 @@
import type { ResolvedData } from '@contentful/optimization-core'
import type { SelectedOptimizationArray } from '@contentful/optimization-core/api-schemas'
+import {
+ OptimizedEntrySourceController,
+ type ContentfulEntryQuery,
+ type OptimizedEntrySourceSnapshot,
+} from '@contentful/optimization-core/entry-source'
import type { Entry, EntrySkeletonType } from 'contentful'
import {
OPTIMIZED_ENTRY_HOST_DISPLAY,
@@ -21,6 +26,7 @@ type HostAttributeValue = string | boolean | number | undefined
export interface ContentfulOptimizedEntryEventDetail {
readonly entry: Entry
+ readonly metadata: OptimizedEntrySnapshot['metadata']
readonly resolvedData: ResolvedData
readonly selectedOptimization: ResolvedData['selectedOptimization']
readonly selectedOptimizations: SelectedOptimizationArray | undefined
@@ -68,14 +74,17 @@ function hasResolvedDataChanged(
export class ContentfulOptimizedEntryElement extends HTMLElement {
static get observedAttributes(): string[] {
- return ['live-updates', 'track-clicks', 'track-hovers', 'track-views']
+ return ['entry-id', 'live-updates', 'track-clicks', 'track-hovers', 'track-views']
}
private explicitRoot: ContentfulOptimizationRootElement | undefined = undefined
private assignedBaselineEntry: Entry | undefined = undefined
+ private assignedEntryQuery: ContentfulEntryQuery | undefined = undefined
private controller: OptimizedEntryController | undefined = undefined
+ private readonly sourceController = new OptimizedEntrySourceController()
private appliedHostAttributes = new Map()
private previousSnapshot: OptimizedEntrySnapshot | undefined = undefined
+ private previousSourceSnapshot: OptimizedEntrySourceSnapshot | undefined = undefined
private optimizationRootContext: ContentfulOptimizationRootContext | undefined = undefined
private unsubscribeFromRootContext: (() => void) | undefined = undefined
private assignedSdk: OptimizedEntrySdk | undefined = undefined
@@ -89,6 +98,28 @@ export class ContentfulOptimizedEntryElement extends HTMLElement {
this.syncEntryController()
}
+ get entryId(): string | undefined {
+ return this.getAttribute('entry-id') ?? undefined
+ }
+
+ set entryId(value: string | undefined) {
+ if (value === undefined) {
+ this.removeAttribute('entry-id')
+ return
+ }
+
+ this.setAttribute('entry-id', value)
+ }
+
+ get entryQuery(): ContentfulEntryQuery | undefined {
+ return this.assignedEntryQuery
+ }
+
+ set entryQuery(value: ContentfulEntryQuery | undefined) {
+ this.assignedEntryQuery = value
+ this.syncEntryController()
+ }
+
get sdk(): OptimizedEntrySdk | undefined {
return this.assignedSdk
}
@@ -143,11 +174,16 @@ export class ContentfulOptimizedEntryElement extends HTMLElement {
connectedCallback(): void {
this.style.display ||= OPTIMIZED_ENTRY_HOST_DISPLAY
+ this.sourceController.setSnapshotListener((snapshot) => {
+ this.applySourceSnapshot(snapshot)
+ })
this.bindRoot()
this.syncEntryController()
}
disconnectedCallback(): void {
+ this.sourceController.disconnect()
+ this.sourceController.setSnapshotListener(undefined)
this.unsubscribeFromRootContext?.()
this.unsubscribeFromRootContext = undefined
this.controller?.disconnect()
@@ -155,6 +191,7 @@ export class ContentfulOptimizedEntryElement extends HTMLElement {
this.controller = undefined
this.optimizationRootContext = undefined
this.previousSnapshot = undefined
+ this.previousSourceSnapshot = undefined
}
attributeChangedCallback(_name: string, oldValue: string | null, newValue: string | null): void {
@@ -211,14 +248,23 @@ export class ContentfulOptimizedEntryElement extends HTMLElement {
}
private syncEntryController(): void {
- const { assignedBaselineEntry: baselineEntry } = this
+ const { sdk, isSdkStateReady } = this.resolveControllerSdk()
+ const previousSourceSnapshot = this.sourceController.getSnapshot()
+ const { entryId } = this
- if (!baselineEntry) {
- this.clearController()
- this.resetPresentationState()
- return
- }
+ this.sourceController.updateOptions({
+ baselineEntry: this.assignedBaselineEntry,
+ entryId: entryId === '' ? undefined : entryId,
+ entryQuery: this.assignedEntryQuery,
+ sdk,
+ isSdkStateReady,
+ })
+ const nextSourceSnapshot = this.sourceController.getSnapshot()
+ this.applySourceSnapshot(nextSourceSnapshot, previousSourceSnapshot === nextSourceSnapshot)
+ }
+
+ private syncBaselineEntryController(baselineEntry: Entry): void {
const { sdk, isSdkStateReady } = this.resolveControllerSdk()
if (!sdk || !isSdkStateReady) {
@@ -262,6 +308,58 @@ export class ContentfulOptimizedEntryElement extends HTMLElement {
}
}
+ private applySourceSnapshot(
+ snapshot: OptimizedEntrySourceSnapshot,
+ forcePresentationUpdate = false,
+ ): void {
+ const { previousSourceSnapshot } = this
+ const sourceSnapshotChanged = previousSourceSnapshot !== snapshot
+ this.previousSourceSnapshot = snapshot
+
+ if (snapshot.baselineEntry !== undefined) {
+ if (sourceSnapshotChanged || forcePresentationUpdate) {
+ this.syncBaselineEntryController(snapshot.baselineEntry)
+ }
+ return
+ }
+
+ this.clearController()
+ this.resetPresentationState()
+ this.applyManagedSourceSnapshot(snapshot, sourceSnapshotChanged, forcePresentationUpdate)
+ }
+
+ private applyManagedSourceSnapshot(
+ snapshot: OptimizedEntrySourceSnapshot,
+ sourceSnapshotChanged: boolean,
+ forcePresentationUpdate: boolean,
+ ): void {
+ if (snapshot.error !== undefined) {
+ if (sourceSnapshotChanged) {
+ this.dispatchEntryError(snapshot.error)
+ }
+ return
+ }
+
+ if (snapshot.isLoading && sourceSnapshotChanged && this.canFetchManagedSource()) {
+ this.dispatchEntryLoading()
+ }
+
+ if (forcePresentationUpdate && snapshot.entryId !== undefined) {
+ this.dispatchRootContextError()
+ }
+ }
+
+ private canFetchManagedSource(): boolean {
+ const { sdk, isSdkStateReady } = this.resolveControllerSdk()
+ return sdk !== undefined && isSdkStateReady
+ }
+
+ private dispatchRootContextError(): void {
+ if (this.optimizationRootContext?.error) {
+ this.dispatchEntryError(this.optimizationRootContext.error)
+ }
+ }
+
private createControllerOptions({
baselineEntry,
sdk,
@@ -371,6 +469,7 @@ export class ContentfulOptimizedEntryElement extends HTMLElement {
composed: true,
detail: {
entry: snapshot.entry,
+ metadata: snapshot.metadata,
resolvedData: snapshot.resolvedData,
selectedOptimization: snapshot.selectedOptimization,
selectedOptimizations: snapshot.selectedOptimizations,
@@ -380,6 +479,15 @@ export class ContentfulOptimizedEntryElement extends HTMLElement {
)
}
+ private dispatchEntryLoading(): void {
+ this.dispatchEvent(
+ new CustomEvent(ENTRY_LOADING_EVENT, {
+ bubbles: true,
+ composed: true,
+ }),
+ )
+ }
+
private dispatchEntryError(error: Error): void {
this.dispatchEvent(
new CustomEvent(ENTRY_ERROR_EVENT, {
diff --git a/packages/web/web-sdk/src/web-components/index.test.ts b/packages/web/web-sdk/src/web-components/index.test.ts
index 29a38075d..4360863c4 100644
--- a/packages/web/web-sdk/src/web-components/index.test.ts
+++ b/packages/web/web-sdk/src/web-components/index.test.ts
@@ -70,6 +70,23 @@ async function resolveVoid(): Promise {
await Promise.resolve()
}
+async function flushMicrotasks(): Promise {
+ await Promise.resolve()
+ await Promise.resolve()
+}
+
+function createDeferred(): {
+ readonly promise: Promise
+ readonly resolve: (value: T) => void
+} {
+ let resolveDeferred: (value: T) => void = () => undefined
+ const promise = new Promise((resolve) => {
+ resolveDeferred = resolve
+ })
+
+ return { promise, resolve: resolveDeferred }
+}
+
function toContentfulOptimization(sdk: TSdk): TSdk & ContentfulOptimization {
Object.setPrototypeOf(sdk, ContentfulOptimization.prototype)
@@ -153,6 +170,8 @@ function createSdk(
consent: () => undefined,
destroy,
flush: resolveVoid,
+ fetchContentfulEntry: async (entryId: string) =>
+ await Promise.resolve(createTestEntry(entryId)),
getFlag: () => undefined,
getMergeTagValue: () => undefined,
hasConsent: () => true,
@@ -230,6 +249,16 @@ function getEntryDetail(event: Event): ContentfulOptimizedEntryEventDetail {
return detail
}
+function getEntryError(event: Event): Error | undefined {
+ if (!(event instanceof CustomEvent)) return undefined
+
+ const { detail }: { detail: unknown } = event
+ if (!isRecord(detail)) return undefined
+
+ const error = Reflect.get(detail, 'error')
+ return error instanceof Error ? error : undefined
+}
+
function ensureElementsDefined(): void {
defineContentfulOptimizationElements()
}
@@ -324,13 +353,21 @@ describe('Contentful Optimization Web Components', () => {
const runtime = createSdk((entry) => ({ entry }))
const root = createRootElement(runtime.sdk)
const entry = createEntryElement(baseline)
- const resolved = rs.fn((event: Event) => getEntryDetail(event).entry.sys.id)
+ const resolved = rs.fn((event: Event) => getEntryDetail(event))
entry.addEventListener('ctfl-entry-resolved', resolved)
root.append(entry)
document.body.append(root)
- expect(resolved).toHaveReturnedWith('baseline')
+ expect(resolved).toHaveReturnedWith(
+ expect.objectContaining({
+ entry: baseline,
+ metadata: expect.objectContaining({
+ baselineEntry: baseline,
+ entry: baseline,
+ }),
+ }),
+ )
expect(entry.style.display).toBe('contents')
expect(entry.dataset.ctflEntryId).toBe('baseline')
expect(entry.dataset.ctflVariantIndex).toBe('0')
@@ -345,7 +382,7 @@ describe('Contentful Optimization Web Components', () => {
const root = createRootElement(runtime.sdk)
const entry = createEntryElement(optimizedBaseline)
const loading = rs.fn()
- const resolved = rs.fn((event: Event) => getEntryDetail(event).entry.sys.id)
+ const resolved = rs.fn((event: Event) => getEntryDetail(event))
entry.addEventListener('ctfl-entry-loading', loading)
entry.addEventListener('ctfl-entry-resolved', resolved)
@@ -361,7 +398,16 @@ describe('Contentful Optimization Web Components', () => {
runtime.canOptimize.emit(true)
runtime.experienceRequestState.emit({ status: 'success' })
- expect(resolved).toHaveReturnedWith('variant-a')
+ expect(resolved).toHaveReturnedWith(
+ expect.objectContaining({
+ entry: variantA,
+ metadata: expect.objectContaining({
+ baselineEntry: optimizedBaseline,
+ entryId: 'variant-a',
+ selectedOptimization: variantOneState[0],
+ }),
+ }),
+ )
expect(entry.dataset.ctflBaselineId).toBe('optimized-baseline')
expect(entry.dataset.ctflEntryId).toBe('variant-a')
expect(entry.dataset.ctflOptimizationId).toBe('exp-hero')
@@ -369,6 +415,117 @@ describe('Contentful Optimization Web Components', () => {
expect(entry.style.visibility).toBe('')
})
+ it('fetches entryId entries through the SDK before resolving', async () => {
+ ensureElementsDefined()
+ const runtime = createSdk((entry) => ({ entry }))
+ const fetchContentfulEntry = rs.fn(async () => await Promise.resolve(baseline))
+ Reflect.set(runtime.sdk, 'fetchContentfulEntry', fetchContentfulEntry)
+ const root = createRootElement(runtime.sdk)
+ const entry = document.createElement('ctfl-optimized-entry')
+
+ if (!(entry instanceof ContentfulOptimizedEntryElement)) {
+ throw new Error('ctfl-optimized-entry is not registered.')
+ }
+
+ const loading = rs.fn()
+ const resolved = rs.fn((event: Event) => getEntryDetail(event))
+
+ entry.entryId = 'baseline'
+ entry.entryQuery = { locale: 'de-DE' }
+ entry.addEventListener('ctfl-entry-loading', loading)
+ entry.addEventListener('ctfl-entry-resolved', resolved)
+ root.append(entry)
+ document.body.append(root)
+ await flushMicrotasks()
+
+ expect(loading).toHaveBeenCalledTimes(1)
+ expect(fetchContentfulEntry).toHaveBeenCalledWith('baseline', { locale: 'de-DE' })
+ expect(resolved).toHaveReturnedWith(
+ expect.objectContaining({
+ entry: baseline,
+ metadata: expect.objectContaining({
+ baselineEntry: baseline,
+ }),
+ }),
+ )
+ expect(entry.dataset.ctflEntryId).toBe('baseline')
+ })
+
+ it('lets baselineEntry take precedence over entryId', () => {
+ ensureElementsDefined()
+ const runtime = createSdk((entry) => ({ entry }))
+ const fetchContentfulEntry = rs.fn(async () => await Promise.resolve(variantA))
+ Reflect.set(runtime.sdk, 'fetchContentfulEntry', fetchContentfulEntry)
+ const root = createRootElement(runtime.sdk)
+ const entry = createEntryElement(baseline)
+
+ entry.entryId = 'variant-a'
+ root.append(entry)
+ document.body.append(root)
+
+ expect(fetchContentfulEntry).not.toHaveBeenCalled()
+ expect(entry.dataset.ctflEntryId).toBe('baseline')
+ })
+
+ it('starts a fresh entryId fetch when baselineEntry precedence is removed', async () => {
+ ensureElementsDefined()
+ const runtime = createSdk((entry) => ({ entry }))
+ const firstFetch = createDeferred()
+ let fetchCount = 0
+ const fetchContentfulEntry = rs.fn(async () => {
+ fetchCount += 1
+ if (fetchCount === 1) return await firstFetch.promise
+ return await Promise.resolve(baseline)
+ })
+ Reflect.set(runtime.sdk, 'fetchContentfulEntry', fetchContentfulEntry)
+ const root = createRootElement(runtime.sdk)
+ const entry = document.createElement('ctfl-optimized-entry')
+
+ if (!(entry instanceof ContentfulOptimizedEntryElement)) {
+ throw new Error('ctfl-optimized-entry is not registered.')
+ }
+
+ entry.entryId = 'baseline'
+ root.append(entry)
+ document.body.append(root)
+
+ expect(fetchContentfulEntry).toHaveBeenCalledTimes(1)
+
+ entry.baselineEntry = variantA
+ expect(entry.dataset.ctflEntryId).toBe('variant-a')
+
+ entry.baselineEntry = undefined
+ await flushMicrotasks()
+
+ expect(fetchContentfulEntry).toHaveBeenCalledTimes(2)
+ expect(entry.dataset.ctflEntryId).toBe('baseline')
+ firstFetch.resolve(baseline)
+ await flushMicrotasks()
+ })
+
+ it('dispatches entry errors from managed entryId fetches', async () => {
+ ensureElementsDefined()
+ const runtime = createSdk((entry) => ({ entry }))
+ const error = new Error('CDA failed')
+ runtime.sdk.fetchContentfulEntry = rs.fn(async () => await Promise.reject(error))
+ const root = createRootElement(runtime.sdk)
+ const entry = document.createElement('ctfl-optimized-entry')
+
+ if (!(entry instanceof ContentfulOptimizedEntryElement)) {
+ throw new Error('ctfl-optimized-entry is not registered.')
+ }
+
+ const errored = rs.fn((event: Event) => getEntryError(event))
+
+ entry.entryId = 'baseline'
+ entry.addEventListener('ctfl-entry-error', errored)
+ root.append(entry)
+ document.body.append(root)
+ await flushMicrotasks()
+
+ expect(errored).toHaveReturnedWith(error)
+ })
+
it('clears presentation state when baselineEntry is unset and resolves again when reused', () => {
ensureElementsDefined()
const runtime = createSdk((entry, selectedOptimizations) => ({