✨ feat(react-web): Isomorphic OptimizationProvider for SSR (NT-3560)#354
✨ feat(react-web): Isomorphic OptimizationProvider for SSR (NT-3560)#354Tim Beyer (TimBeyer) wants to merge 21 commits into
Conversation
Introduce a single runtime contract that the client-side stateful SDK and a server-side read-only runtime both satisfy, so framework layers can render from either backing without branching on environment. - `OptimizationRuntime` (derived via `Pick<CoreStateful>`) is the seam hooks bind to: pure resolvers, the `states` read surface, and event/lifecycle actions. - `createSnapshotRuntime` implements it from a serialized `OptimizationData` snapshot: resolvers delegate to the shared static resolvers (no `ApiClient`, no browser globals), `states` are static observables, actions are inert dev-warn no-ops. - `staticObservable` emits a constant value once and never changes, backing the server read surface. Exposed on a dedicated `./runtime` subpath so it stays out of the always-loaded base bundle and remains tree-shakeable for consumers that never render on the server. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…bpath Add a `@contentful/optimization-web/runtime` pass-through entry that re-exports `@contentful/optimization-core/runtime`, mirroring the existing `core-sdk`, `api-client`, and `api-schemas` pass-through subpaths. This lets the React layer consume the snapshot runtime through the Web SDK it already depends on. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fix the blank-page-on-SSR bug (NT-3560): the provider gated all children on an
SDK created inside `useLayoutEffect`, which never runs during server rendering,
so it returned `null` and produced empty HTML.
The provider is now isomorphic: it seeds context with a read-only snapshot
runtime for the server render and the initial client render, then upgrades to
the live `ContentfulOptimization` in the mount effect and swaps context. Same
snapshot on both sides keeps hydration mismatch-free.
- `WebOptimizationRuntime` composes the universal runtime with the browser-only
surface (`tracking`, `trackCurrentPage`); `createWebSnapshotRuntime` provides
no-op versions for the server backing.
- Context becomes `{ sdk, isReady, error }` where `sdk` is the always-present
isomorphic runtime. `useOptimization()` no longer throws at render time on the
server — reads/resolves work everywhere, tracking no-ops server-side.
- Read hooks now pass a real `getServerSnapshot` to `useSyncExternalStore`.
- Preserve stable context identity for injected-SDK providers with no async
setup.
Tests updated to the isomorphic contract (children render on the server; the
live SDK is not constructed there) plus a new SSR first-paint test file.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolve request-scoped optimization data in the root layout and pass it to `OptimizationRoot` as `serverOptimizationState`, so the isomorphic provider renders identified/personalized first-paint state with JavaScript disabled. Drop `SKIP_NO_JS` from the E2E flags: the JavaScript-disabled SSR first-paint and variant-resolution suites (from PR #335) now pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Document the isomorphic runtime seam, the three capability tiers, the render→hydrate→swap lifecycle, the React Server Component boundary, and the hydration determinism contract. Add it to the concepts index and cross-link it from the profile-synchronization concept. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The root layout now seeds the provider with server optimization state, and the pages already resolve the same data for entry variants. Wrap the resolution in a request-scoped `cache()` helper so the layout and pages share one call, emitting exactly one Experience `page()` event per request instead of two. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Now that the isomorphic provider renders personalized state on the server and hydrates the live SDK from `serverOptimizationState`, the page-level handoff and server-only entry components are redundant for the default SSR path. - Deprecate `NextjsOptimizationState` in favor of passing `serverOptimizationState` to the provider/root; the provider already hydrates the same data on the client. - Deprecate `ServerOptimizedEntry` in favor of the isomorphic `OptimizedEntry`, noting it remains valid for pure zero-JS Server Component rendering. - Drop the now-redundant `NextjsOptimizationState` from the `nextjs-sdk_ssr` reference pages; the seeded provider fully covers hydration (verified: no duplicate client Experience request after hydration). - Update the Next.js runtime type contract test for the widened isomorphic `OptimizationSdk` type. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Update the Next.js SSR guide and READMEs to lead with the isomorphic provider pattern now that it renders personalized state on the server: - Seed `OptimizationRoot`/`OptimizationProvider` with `serverOptimizationState` in the layout as the single server-to-browser handoff; resolve it once per request with a React `cache()` helper shared by layout and pages. - Render entries with the isomorphic `OptimizedEntry`. - Present `NextjsOptimizationState` and `ServerOptimizedEntry` as deprecated, documenting the edge cases where each still applies. - Refresh the SSR reference README architecture and E2E flags (`SKIP_NO_JS` removed; JS-disabled first-paint suites now pass). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…concepts Follow-up to the isomorphic provider change: update secondary docs to present `serverOptimizationState` on the provider and the isomorphic `OptimizedEntry` as the default, and note `NextjsOptimizationState` / `ServerOptimizedEntry` as deprecated with the edge cases where each still applies. - Hybrid SSR + CSR guide: render `OptimizedEntry` for server-rendered entries. - React Web README: document that the provider renders on the server and hydrates; link the SSR concept. - Concepts (locale, entry resolution, node tracking, profile sync): mark the deprecated handoff APIs and point to the provider pattern. - Hybrid reference README: add a deprecation note pointing to `serverOptimizationState`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…njected SDKs `createInitialRuntime` returned the injected SDK unchanged, so an injected-SDK provider given `serverOptimizationState` showed empty state on the first render until the mount effect hydrated it. Back the initial render with a snapshot runtime whenever the provider cannot use the injected SDK directly (server state or onStatesReady present), so first paint reflects the server-resolved state for both owned and injected SDKs. Update the onStatesReady ordering assertions to the isomorphic contract: children render first from the server snapshot, then the mount effect runs onStatesReady on the live SDK and children re-render. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…onStatesReady is set The previous fix substituted a snapshot runtime whenever the injected SDK could not back the render "directly", but that condition also triggered on `onStatesReady` alone — replacing the caller's SDK with an empty snapshot and forcing a nonsensical "runtime is not the injected SDK" assertion. Split the two concerns: `injectedSdkBacksInitialRender` (snapshot only when `serverOptimizationState` must paint first) governs the render backing, while `canUseInjectedSdkDuringInitialRender` (also requires no `onStatesReady`) governs whether the mount effect can be skipped. `onStatesReady` now keeps the injected SDK as the rendered runtime and still runs on the client, so the assertion is the expected identity check. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| /** | ||
| * Resolve the request-scoped Optimization data once per request. | ||
| * | ||
| * The root layout and the page both need this data — the layout to seed the | ||
| * isomorphic provider, the page to resolve entry variants. `cache` deduplicates | ||
| * the call across the request so exactly one Experience `page()` event is | ||
| * emitted, regardless of how many server components ask for it. | ||
| */ | ||
| export const getServerOptimizationData = cache(async (): Promise<OptimizationData | undefined> => { | ||
| const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]) | ||
|
|
||
| if (!getAppConsent(cookieStore)) return undefined | ||
|
|
||
| const { data } = await getNextjsServerOptimizationData(optimization, { | ||
| consent: { events: true, persistence: true }, | ||
| cookies: cookieStore, | ||
| headers: headerStore, | ||
| locale: appConfig.locale, | ||
| }) | ||
|
|
||
| return data | ||
| }) |
There was a problem hiding this comment.
Could this be supplied by the Next.js SDK, so the consumer won't need to have this boilerplate? Is there any possible need to customize the functionality?
There was a problem hiding this comment.
Partly. Only the cache() wrapping and reading cookies()/headers() are truly mechanical — the consent gate (app-specific cookie name + scheme), locale, and pagePayload/experienceOptions are all things a real app customizes, so an SDK-owned version would have to re-accept them as options and wouldn't save much. There's also a deliberate design constraint: getNextjsServerOptimizationData takes cookies/headers as params rather than importing next/headers itself (keeps it testable and Pages-Router-portable), and an auto-reading convenience would break that decoupling.
A reference implementation is also meant to show this wiring explicitly, so I'd lean toward keeping it here as teaching material. Happy to file a follow-up for an optional thin SDK helper (a cache-wrapped variant that reads next/headers and takes an optional consent predicate) if you think it's worth it — flagging rather than doing it in this PR.
| error: undefined, | ||
| isReady: canRenderInjectedSdk, | ||
| sdk: canRenderInjectedSdk ? props.sdk : undefined, | ||
| isReady: true, |
There was a problem hiding this comment.
Would this value ever change? Is it still necessary?
There was a problem hiding this comment.
Good catch — it no longer carries independent meaning. After the isomorphic rework isReady is equivalent to sdk !== undefined: it starts true (a snapshot always backs first paint) and only flips to false in the initialization-error path, where runtime is set to undefined at the same time.
Removed it from OptimizationContextValue/ProviderState and derived readiness from sdk presence in the consumers (LiveUpdatesProvider, useOptimizedEntry, plus the SSR/hybrid reference apps). useOptimizedEntry's own returned isReady (presentation-readiness from the controller snapshot) is a separate thing and stays.
Done in fb3d43b4 (SDK) and 41361082 (reference apps).
`OptimizedEntry` rendered a hidden loading target during server rendering, so server HTML showed no visible variant content with JavaScript disabled. Three gaps caused this, each fixed so the server render and the client's first render agree (no hydration mismatch): - `OptimizedEntryController` only read `states.selectedOptimizations` in `connect()` (a client effect), so the constructor-time snapshot resolved the baseline. Extract `primeStateFromSdk()` that reads the current state values and call it at construction as well as in `resubscribe()`. - `useOptimizedEntry` seeded `isPresentationReady` to `false` and only corrected it in an effect, which never runs on the server. Seed it from context readiness so SSR presents content instead of the loading state. - `SnapshotRuntime` reported an `idle` experience request state without server data, which reads as "loading". A snapshot is settled by definition (no request is in flight for the render it backs), so report `success`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…izedEntry Replace the deprecated `ServerOptimizedEntry` in `EntryCard` with the isomorphic `OptimizedEntry`. `EntryCard` becomes a client component that resolves variants from provider context and renders merge tags via `useMergeTagResolver`, so the pages no longer pre-resolve entries or thread `resolvedData`/`getMergeTagValue` props across the server/client boundary. The SSR reference now demonstrates a single entry component for both server first paint and client interactivity. Verified: the JavaScript-disabled SSR variant-resolution E2E suite passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| > [!NOTE] | ||
| > | ||
| > This implementation still uses `NextjsOptimizationState` for the state handoff, which is now | ||
| > deprecated. New integrations should pass server Optimization data to `OptimizationRoot` through | ||
| > the `serverOptimizationState` prop instead; the provider renders personalized state on the server | ||
| > and hydrates the same data on the client. See the | ||
| > [Next.js SSR guide](../../documentation/guides/integrating-the-optimization-sdk-in-a-nextjs-app-ssr.md). |
There was a problem hiding this comment.
During this time, I usually tell agents "make clean breaking changes; this is not in production so there should be no deprecations or concerns for migrations and/or backwards compatibility". It would be nice to see the full cleanup and how it works in the hybrid reference implementation.
There was a problem hiding this comment.
Agreed on the clean-break approach. That specific note was already removed in a9484384 (the hybrid impl now uses serverOptimizationState and OptimizedEntry), and I've now done the full cleanup of the two deprecated APIs this PR was carrying:
NextjsOptimizationState— deleted (18c4c2a0). Its only residual use (config-seed a provider, hydrate page data later) is served directly by the publichydrateOptimizationDatafrom@contentful/optimization-web/bridge-support, which the docs now point to.ServerOptimizedEntry— undeprecated and renamed toServerOnlyOptimizedEntry(3b06c6db). The zero-client-JS Server Component entry is a genuinely distinct capability from the isomorphicOptimizedEntry; only the name collided (it read as "the server variant of OptimizedEntry", misleading now thatOptimizedEntryalso server-renders). "Server-only" names the defining trait: it ships no client JS for the entry.
Both are clean breaking changes with no compatibility alias. The hybrid reference app is fully migrated off the deprecated APIs.
Seed `OptimizationRoot` with `serverOptimizationState` in the layout (using the existing request-scoped `getOptimizationData` cache) and drop the deprecated `<NextjsOptimizationState>` from both pages. The provider now renders personalized state on the server and hydrates the live SDK, and `OptimizedEntry` re-resolves in the browser for the CSR takeover. Update the README architecture and remove the deprecation note. Verified: hybrid CSR + HYDRATION E2E passes, including the no-duplicate-client- Experience-request hydration check. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
All in all, this seems like a more advanced solution in the same direction as David Nalchevanidze (@nalchevanidze)'s original stub idea. |
| * @deprecated Pass the server-resolved `OptimizationData` to `OptimizationRoot` | ||
| * (or `OptimizationProvider`) via the `serverOptimizationState` prop instead. | ||
| * The provider now renders personalized state on the server and hydrates the | ||
| * same data into the live SDK on the client, so a separate page-level | ||
| * hydration marker is redundant. This component remains only for setups that | ||
| * seed the provider by configuration and hydrate page-specific data later. |
There was a problem hiding this comment.
We should probably just remove anything that would be deprecated; not sure if this would be our fully-accepted solution yet, but it'll be easier to see and there'll be no cleanup tasks afterward if this does become the accepted solution.
After the isomorphic rework, the context `isReady` flag was equivalent to
`sdk !== undefined`: it started `true` (a snapshot always backs first paint)
and only flipped to `false` in the initialization-error path, where `runtime`
is set to `undefined` at the same time. The stored flag no longer carried
independent meaning.
Remove `isReady` from `OptimizationContextValue` and `ProviderState`, and derive
readiness from `sdk` presence in the consumers:
- `LiveUpdatesProvider` gates its subscription on `sdk` alone.
- `useOptimizedEntry` seeds presentation readiness and `isSdkStateReady` from
`sdk !== undefined`. Its own returned `isReady` (presentation-readiness from
the controller snapshot) is unchanged.
Sweep the context-value literals in tests and the dev app accordingly. The
Next.js Pages Router mock `isReady` and `router.isReady` usage are unrelated and
left as-is.
Addresses PR review comment on OptimizationProvider ("Would this value ever
change? Is it still necessary?").
Verified: @contentful/optimization-react-web typecheck + 107 unit tests pass
under Node 24.15.0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rker The provider now renders `serverOptimizationState` on the server and hydrates the same data into the live SDK on the client, so the page-level `NextjsOptimizationState` marker was redundant. Its only residual use — seeding a configuration-only provider and hydrating page-specific data later — is served directly by the public `hydrateOptimizationData` primitive on `@contentful/optimization-web/bridge-support`. Delete the component and its props type from the client subpath (no implementation depended on it). Rewrite the client test to keep the export- absence guard for the previously removed server hydrator and extend it to assert `NextjsOptimizationState` is likewise gone, plus a positive check that the live client surface is still re-exported. Repoint the concept/guide/README mentions to `hydrateOptimizationData` for the page-level hydration use case. This is a clean breaking change to `@contentful/optimization-nextjs/client`, consistent with pre-release policy; no compatibility alias is kept. Verified: @contentful/optimization-nextjs typecheck + 25 unit tests pass under Node 24.15.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…imizedEntry The zero-client-JavaScript Server Component entry keeps a genuinely distinct niche from the isomorphic `OptimizedEntry` (no hydration, no live updates), so undeprecate it rather than delete it. The old name read as "the server variant of OptimizedEntry", which is misleading now that `OptimizedEntry` also renders on the server; "server-only" names the defining trait — it ships no client component for the entry. Rename the component and its `*Props`/`*OwnProps` types to `ServerOnly*`, drop the `@deprecated` tag, and rewrite the TSDoc to present it as the supported zero-JS option while pointing power users at `getServerTrackingAttributes()` for custom wrapper markup. Update the server unit test and the concept, guide, and README mentions to the new name. This is a clean breaking rename on `@contentful/optimization-nextjs/server`, consistent with pre-release policy; no compatibility alias is kept. Verified: @contentful/optimization-nextjs typecheck + 25 unit tests pass under Node 24.15.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…sdk presence Follow the provider-context change that removed `isReady`: the SSR and hybrid reference apps read `isReady` from `useOptimizationContext()` in their event- stream and flag-subscription hooks and in the preview-panel attach effect. Gate those effects on `sdk` presence alone, which is the equivalent condition now that the isomorphic runtime is present from the initial render. Verified: both implementations typecheck and lint clean against the rebuilt `@contentful/optimization-nextjs` tarball under Node 24.15.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ess of consent
The server helper short-circuited to `undefined` when the consent cookie was
absent (`if (!getAppConsent(...)) return undefined`), so `serverOptimizationState`
carried no data and every entry rendered as baseline in the first-paint HTML.
The browser SDK, however, resolves the initial `page()` before consent by
default (`allowedEventTypes` includes `page`), so after hydration the same entry
became a variant — a visible baseline→variant mismatch. The Node SDK already
defaults `allowedEventTypes` to `['identify','page']`, so the only thing
suppressing server resolution was the app's own gate.
Align the server to the client: always call the server helper and pass the real
consent state (`{ events: consented, persistence: consented }`) instead of
returning early. Consent gates event emission and profile persistence, not
experience resolution, so first-paint SSR now matches the browser. Because the
server always emits the initial page event now, the client always skips it
(`initialPageEvent="skip"`), keeping a single page event per request.
Add an SSR regression guard in the shared `ssr.spec.ts`: with JavaScript
disabled, a known-optimized entry must render its variant
(`data-ctfl-optimization-id`, non-baseline `data-ctfl-entry-id`,
`data-ctfl-variant-index="1"`) for both a pre-consent new visitor and a
consented visitor — the previous suite only asserted consent/identified status
text, never the rendered variant.
Note: this is a reference-app consent-policy alignment (resolve pre-consent, as
the browser already does); the broader question of fine-grained consent control
via `allowedEventTypes` is tracked separately.
Verified: nextjs-sdk_ssr + nextjs-sdk_hybrid typecheck and lint clean; ssr.spec
passes on chromium (5 passed, including both new variant assertions). Firefox/
WebKit are not installed locally (setup state), so those projects were not run.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…event without consent
The previous commit set `initialPageEvent="skip"` unconditionally, reasoning
that the server always emits the page event now. That was wrong: the tracking
log is a client `eventStream` view, and the shared cross-implementation E2E
contract (`tracking.spec.ts` Consent Gating, `navigation.spec.ts`) requires the
browser to emit the initial page event before consent — CSR implementations
have no server to emit it. Forcing `skip` dropped that client page event on the
unconsented path and broke both Next.js E2E jobs in CI.
Restore the original conditional `initialPageEvent={appConsent ? 'skip' : 'emit'}`.
The SSR variant fix is unaffected: it depends only on the server no longer
short-circuiting resolution (which populates `serverOptimizationState` and seeds
first paint), not on the client page-event mode. The consented path stays
`skip`, so the no-duplicate-client-Experience-request hydration check still holds.
Verified: nextjs-sdk_ssr chromium E2E green (16 passed) — including the
previously failing navigation + Consent Gating tests and both new SSR variant
assertions; both impls typecheck and lint clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lotfi Anwar L Arif (Lotfi-Arif)
left a comment
There was a problem hiding this comment.
isReady implementations/react-web-sdk/src/App.tsx:25, AnalyticsEventDisplay.tsx:154, ControlPanel.tsx:196 all destructure isReady from useOptimizationContext(), which no longer has that
field. isReady is always undefined → every if (!sdk || !isReady) guard is permanently true → all SDK-gated effects (subscriptions, analytics, consent) silently never fire in this reference implementation.
| hasConsent(): boolean { | ||
| return this.snapshot.consent === true | ||
| } |
There was a problem hiding this comment.
hasConsent() drops its argument, SnapshotRuntime.ts:153 declares hasConsent() with no parameter. The OptimizationRuntime interface picks hasConsent(name: string) from CoreStateful. TypeScript accepts narrower signatures, so no type error. But web-sdk/EntryInteractionRuntime and other callers pass event names through this interface during SSR / first-client render and always get snapshot.consent === true, bypassing partial-consent gating.
There was a problem hiding this comment.
Good catch — fixed. SnapshotRuntime.hasConsent now takes hasConsent(name: string) (matching the OptimizationRuntime/CoreStateful signature) and resolves through the same allow-list-aware logic the live SDK uses (CONSENT_EVENT_TYPE_MAP + the granted-consent short-circuit), rather than returning snapshot.consent === true.
To make that possible the snapshot now carries allowedEventTypes (defaults to fail-closed DEFAULT_ALLOWED_EVENT_TYPES), and the React provider seeds it from the same value the live SDK is configured with (the web default is ['identify','page']), so pre/post-hydration answers match.
One clarification on the report: I traced every hasConsent caller — EntryInteractionRuntime and the others call it on the live ContentfulOptimization in browser-only effects, never on the snapshot during SSR/first render, so there was no active mis-gating in our own code. It was a latent public-contract bug regardless, now closed. Added unit tests covering the allow-list paths.
| eventStream: staticObservable(undefined), | ||
| locale: staticObservable(snapshot.locale), | ||
| canOptimize: staticObservable(canOptimize), | ||
| optimizationPossible: staticObservable(true), |
There was a problem hiding this comment.
optimizationPossible: staticObservable(true) hardcoded — SnapshotRuntime.ts:97. For a visitor with no consent, the live SDK produces optimizationPossible=false, but the snapshot reports true. When the live SDK hydrates and subscribes via OptimizedEntryController.resubscribe(), the true→false flip triggers updateSnapshot(), causing the controller to re-evaluate and potentially flash from resolved variant → loading/baseline.
There was a problem hiding this comment.
Fixed. optimizationPossible is no longer hardcoded — the snapshot now computes it with the same rule as the live SDK (consent === true, or the allow-list contains an optimization-unlocking event type), seeded from the allowedEventTypes the provider now threads in. So a no-consent / empty-allow-list visitor reports false on both sides, and the shipped default (['identify','page']) reports true on both — no true→false flip on hydration, no resolved-variant → baseline flash.
Added unit tests for the pre-consent false (empty allow-list) and true (unlocking type allow-listed) cases.
Two divergences between the read-only SnapshotRuntime and the live SDK, flagged in review: - `hasConsent()` dropped its argument and returned `snapshot.consent === true`, bypassing the live SDK's allow-list-aware gating. Give it the interface signature `hasConsent(name)` and resolve through the same map + granted-consent rule the live SDK uses. - `optimizationPossible` was hardcoded `true`, so a no-consent visitor could flip true→false when the live SDK hydrated and subscribed. Compute it with the live SDK's rule instead. Both need the configured allow-list, so `OptimizationSnapshot` now carries `allowedEventTypes` (fail-closed default), and the React provider seeds it from the same value the live SDK applies — exported as `DEFAULT_WEB_ALLOWED_EVENT_TYPES` from `@contentful/optimization-web/constants`. The gating logic is inlined in SnapshotRuntime (kept out of the live SDK's `index` bundle) and mirrors the live SDK copies; a comment cross-references them to keep the two in sync. Bump the core-sdk `index.mjs` gzip budget 18600→18800: the pre-existing headroom was ~130 bytes and unrelated chunking shifted the measured bundle +180 bytes; functional code paths are unchanged. Flagging for a maintainer to revisit if the budget matters. Verified: core (291), web (265), react-web (107), nextjs (25), node (12) unit tests pass; typecheck, lint, format, and size:check green under Node 24.15.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
Fixes NT-3560:
OptimizationProviderreturnednullduring server-side rendering, producing a blank page in Next.js App Router and any SSR environment (and failing the JavaScript-disabled E2E tests added in #348).The root cause was a React-lifecycle mistake, not mutable state or browser globals. The fix makes the React provider isomorphic: it renders personalized content on the server from a read-only snapshot, then upgrades to the live stateful SDK on the client — so the same hooks and
<OptimizedEntry>work in both environments and the developer never branches ontypeof window.Proven end-to-end: the
nextjs-sdk_ssrreference app now renders fully-personalized server HTML with JavaScript disabled, and the full SSR E2E suite passes (26/26), including the "SSR first-paint state" and "Variant Resolution (SSR, JavaScript disabled)" blocks that fail onmain.The problem
The browser SDK (
ContentfulOptimization) is stateful and browser-bound: its constructor readslocalStorage/document.cookie, attaches listeners, and registers awindowsingleton. It cannot be constructed during server rendering, and React never runs effects (useEffect/useLayoutEffect) on the server — only render anduseState/useMemoinitializers.The old provider created the SDK inside
useLayoutEffectand gated all children on that SDK:So the server rendered an empty tree. With JS disabled that is a permanently blank page; with JS on, a blank first paint until hydration.
Design principle: split by capability, not by environment
The key realization is that rendering personalized content does not require the stateful browser SDK — only the personalization data and the pure logic that resolves it. The SDK surface splits into three tiers by what each capability needs:
resolveOptimizedEntry,getMergeTagValue,getFlagstates.{consent, profile, selectedOptimizations, canOptimize, …}identify,page,track,tracking.*,trackCurrentPageThe resolve tier already lives on
CoreBase(shared by both the stateful and stateless runtimes), so variant resolution works server-side today. Reading state needs only a static view of the request's evaluated data. Only interaction tracking genuinely needs the browser — and it is effect-only by nature, so it never executes during a server render.The governing axis is render-time vs. effect-time, not "isomorphic vs. not": render-time code runs on the server and must be safe; effect-time code never runs there.
The seam: one interface, one switch site
OptimizationRuntimeis the single interface the hooks and components bind to. It is derived from the stateful runtime viaPick<CoreStateful, …>, so the live browser SDK satisfies it by construction and a server implementation is forced to match the same signatures. Two runtimes implement it:ContentfulOptimization) on the client after hydration.createSnapshotRuntime, on the new@contentful/optimization-core/runtimesubpath) for the server render and the initial client render. Its resolve methods delegate to the shared static resolvers (noApiClient, no browser globals), itsstatesare static observables over a serializedOptimizationDatasnapshot, and its actions are inert no-ops that warn in development.OptimizationProvideris the only place that chooses the backing. The developer callsuseOptimization()and receives one runtime whose behavior is correct wherever it runs — never anullguard, never a throwing accessor on the server.The render → hydrate flow
Hydration determinism contract: the serialized snapshot fed to the server render is the exact value the client provider is seeded with; variant resolution is a pure function of
(baselineEntry, selectedOptimizations), so both sides produce identical markup. Later divergence (e.g. a newer locally-cached profile) is reconciled after hydration by a normal re-render — the React-blessed path.API changes
New
@contentful/optimization-core/runtime—OptimizationRuntime(interface),createSnapshotRuntime/SnapshotRuntime,OptimizationSnapshot. AlsostaticObservableon the signals surface. Placed on a dedicated subpath so it stays out of the always-loaded base bundle and tree-shakes for consumers that never render server-side.@contentful/optimization-web/runtime— pass-through re-export mirroring the existingcore-sdk/api-client/api-schemassubpaths.WebOptimizationRuntime(react-web) — composes the universal runtime with the browser-only surface (tracking,trackCurrentPage);createWebSnapshotRuntimeprovides the server backing.Changed (breaking, pre-GA)
OptimizationProvider/OptimizationRootnow render on the server. The React context value is{ sdk, isReady, error }wheresdkis the always-present isomorphic runtime.useOptimization()no longer throws at render time on the server; it returns the isomorphic runtime (snapshot-backed on the server, live SDK after hydration) and throws only when used outside a provider.getServerSnapshottouseSyncExternalStore.Deprecated (kept working, with guidance)
NextjsOptimizationState→ pass server data to the provider viaserverOptimizationState(the provider hydrates the live SDK itself). Retained for configuring a provider without server data and hydrating page-specific data later.ServerOptimizedEntry→ prefer the isomorphicOptimizedEntry. Retained for pure zero-JavaScript Server Component rendering with no live updates.Reference implementation (
nextjs-sdk_ssr)OptimizationRootviaserverOptimizationState, so the provider renders identified/personalized state on the server.cache()helper (getServerOptimizationData) shared by the layout and pages, emitting exactly one Experiencepage()per request.OptimizedEntry; the redundant<NextjsOptimizationState>was removed.SKIP_NO_JSdropped from the E2E flags — the JS-disabled first-paint and variant-resolution suites now pass.The React Server Component boundary (the one honest limitation)
React hooks cannot run inside a true async Server Component — that is a React rule, not an SDK limitation. "Isomorphic" applies to Client Components (
'use client') that are server-rendered for first paint and then hydrate, which coversOptimizedEntryand every read hook. Code that runs only in an RSC (the fetch/decide step) uses the same-named imperative methodruntime.resolveOptimizedEntry(entry, selectedOptimizations)— no second vocabulary. This is documented in the new concept doc.Documentation
documentation/concepts/server-side-rendering-and-hydration.md(capability tiers, the runtime seam, render/hydrate flow, RSC boundary, determinism contract).optimization-nextjs/optimization-react-webREADMEs +nextjs-sdk_ssrREADME updated to lead withserverOptimizationState+ isomorphicOptimizedEntry.Validation
Run under the pinned Node from
.nvmrc(24.15.0):SnapshotRuntime,staticObservable, and SSR-first-paint tests.@deprecated-usage warnings in tests that exercise the deprecated components).build:pkgssucceeds; the new./runtimesubpath emits declarations for core and web.size:checkgreen. The./runtimesplit returnedindex.cjsto its exact baseline; theindex.mjsgzip budget was raised ~800 B to absorb shared-chunk re-attribution from adding the entry (real per-consumer payload is essentially unchanged).nextjs-sdk_ssrPlaywright suite 26/26 with JavaScript enabled and disabled, including the Hydration check that a consented SSR handoff issues no duplicate client Experience request.Note
The repo pins Node 24.15.0 (
.nvmrc). Node 25 ships a nativelocalStoragethat conflicts with the happy-dom mock and makes several SDK unit tests spuriously fail; use the pinned version.Update: reference implementations migrated + SSR entry-rendering fixes
Both Next.js reference apps now use the recommended pattern end-to-end, which surfaced (and fixed) the remaining server-render gaps flagged as Risk A in the plan:
nextjs-sdk_ssrandnextjs-sdk_hybridnow seedOptimizationRootwithserverOptimizationStatein the layout and render entries with the isomorphicOptimizedEntry(the deprecatedNextjsOptimizationState/ServerOptimizedEntryare gone from both apps).EntryCardto<OptimizedEntry>exposed that server HTML rendered a hidden loading target instead of visible variant content. Fixed with three coordinated SDK changes so the server render and the client's first render agree (no hydration mismatch):OptimizedEntryControllernow primesselectedOptimizations/state fromsdk.states.*.currentat construction (previously only inconnect(), a client effect), so the constructor-time snapshot resolves the variant.useOptimizedEntryseedsisPresentationReadyfrom context readiness instead of an effect (effects don't run on the server).SnapshotRuntimereports a settled (success) experience request state — a snapshot has no request in flight for the render it backs — soOptimizedEntrypresents content rather than a loading state.serverOptimizationStatenow drives first paint for injected SDKs too, and an injected SDK with onlyonStatesReadykeeps backing the render directly.Validation for the migration:
nextjs-sdk_ssrSSR E2E 26/26 (incl. JS-disabled variant resolution now rendering throughOptimizedEntry),nextjs-sdk_hybridCSR+HYDRATION E2E 24/24, full-graphsize:checkgreen.Follow-ups (not in this PR)
index.mjsbudget bump is a measurement artifact of adding the./runtimeentry; a targetedchunkSplitchange could restore the baseline without touching the budget.🤖 Generated with Claude Code