Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
e56c4ae
🐞 fix(nextjs-ssr): Fix SSR rendering — always render children, use se…
nalchevanidze Jun 30, 2026
15be51d
💡 refactor(nextjs-ssr): Clean up lib utilities and resolution module
nalchevanidze Jun 30, 2026
c6f2bc3
💡 refactor(nextjs-ssr): Remove unused Tailwind CSS setup
nalchevanidze Jun 30, 2026
a198006
💡 refactor(nextjs-ssr): Further cleanup of components and resolution …
nalchevanidze Jun 30, 2026
0d93743
💡 refactor(nextjs-ssr): Fetch and resolve entry links lazily inside f…
nalchevanidze Jun 30, 2026
8dc540a
💡 refactor(nextjs-ssr): Consolidate optimization logic into ServerOpt…
nalchevanidze Jun 30, 2026
a7a0679
💡 refactor(nextjs-ssr): Finalize ServerOptimization API and clean up …
nalchevanidze Jun 30, 2026
b373f34
✨ feat(nextjs-ssr): Pass server-side consent, identified and optimiza…
nalchevanidze Jun 30, 2026
1e5922f
💡 refactor(nextjs-ssr): Consolidate ControlPanel hooks into useContro…
nalchevanidze Jun 30, 2026
8d338d9
💡 refactor(nextjs-ssr): Flatten serverState and derive ControlPanelSe…
nalchevanidze Jun 30, 2026
c390f10
fix(nextjs-ssr): Stop using serverState.profile after SDK is ready
nalchevanidze Jun 30, 2026
4514716
refactor(nextjs-ssr): Remove double entry-card wrapper in EntryCardCl…
nalchevanidze Jun 30, 2026
e372370
fix(nextjs-ssr): Include consent in OptimizationRoot defaults
nalchevanidze Jun 30, 2026
3375600
fix(nextjs-ssr): Restore content-* and entry-text-* testids in EntryC…
nalchevanidze Jun 30, 2026
38a5f6a
refactor(nextjs-ssr): Simplify EntryCard props and move isRichTextFie…
nalchevanidze Jun 30, 2026
20a3dbb
test(e2e-web): Add SSR first-paint state tests and refactor shared co…
nalchevanidze Jul 1, 2026
385a1bb
chore: Merge origin/main into fix-ssr
nalchevanidze Jul 1, 2026
6c22250
refactor(nextjs-ssr): Extract isMergeTagNode to util and remove cast
nalchevanidze Jul 1, 2026
0fd6a79
refactor(nextjs-ssr): Resolve merge tags to text nodes like Angular
nalchevanidze Jul 1, 2026
f4238a2
refactor(fix-ssr): Remove react-web-sdk SSR stub — moved to experimen…
nalchevanidze Jul 1, 2026
f32dca2
chore: Merge origin/main into fix-ssr
nalchevanidze Jul 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions implementations/nextjs-sdk_ssr/.env.example
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
DOTENV_CONFIG_QUIET=true

# SKIP_NO_JS skips the JavaScript-disabled variant resolution suite (SSR, JavaScript disabled).
E2E_FLAGS=SSR,SKIP_NO_JS
E2E_FLAGS=CSR,HYDRATION,SSR
APP_PORT=3001

PUBLIC_NINETAILED_CLIENT_ID="mock-client-id"
Expand Down
3 changes: 1 addition & 2 deletions implementations/nextjs-sdk_ssr/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,7 @@ server/client SDK composition; app code imports only Next.js SDK subpaths.

- All E2E tests live in `lib/e2e-web`. Hydration-specific specs are gated with `runIf('HYDRATION')`
and run automatically when `E2E_FLAGS=CSR,HYDRATION`. This implementation uses
`E2E_FLAGS=SSR,SKIP_NO_JS`: SSR enables the SSR suite, and `SKIP_NO_JS` skips the
JavaScript-disabled variant resolution suite (`Variant Resolution (SSR, JavaScript disabled)`).
`E2E_FLAGS=CSR,HYDRATION,SSR`.
- Entry cards must expose `data-ctfl-entry-id` on the `content-*` element so shared selectors work.
- `test:e2e` delegates to `lib/e2e-web`.

Expand Down
1 change: 0 additions & 1 deletion implementations/nextjs-sdk_ssr/app/globals.css

This file was deleted.

22 changes: 13 additions & 9 deletions implementations/nextjs-sdk_ssr/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,24 @@ import { GlobalLiveUpdatesProvider } from '@/components/GlobalLiveUpdatesProvide
import { PreviewPanel } from '@/components/PreviewPanel'
import { TrackingLog } from '@/components/TrackingLog'
import { appConfig } from '@/lib/config'
import { getAppConsent } from '@/lib/util'
import { optimization } from '@/lib/optimization'
import { NextAppAutoPageTracker, OptimizationRoot } from '@contentful/optimization-nextjs/client'
import 'e2e-web/theme.css'
import type { Metadata } from 'next'
import { cookies } from 'next/headers'
import Link from 'next/link'
import { Suspense, type ReactNode } from 'react'
import { type ReactNode } from 'react'

export const metadata: Metadata = {
title: 'Optimization Next.js SDK SSR',
description:
'Next.js App Router reference: the Next.js SDK resolves entries server-side and handles client-side tracking and interactive controls.',
}

export const dynamic = 'force-dynamic'

export default async function RootLayout({ children }: Readonly<{ children: ReactNode }>) {
const cookieStore = await cookies()
const appConsent = getAppConsent(cookieStore)
const { profile, selectedOptimizations, changes, initialPageEvent, hasConsent } =
await optimization.getServerState()

return (
<html lang={appConfig.locale.split('-')[0]}>
Expand All @@ -30,17 +31,20 @@ export default async function RootLayout({ children }: Readonly<{ children: Reac
api={appConfig.api}
trackEntryInteraction={{ views: true, clicks: true, hovers: true }}
logLevel="debug"
defaults={{
consent: hasConsent,
persistenceConsent: hasConsent,
...(profile ? { profile } : {}),
...(hasConsent && selectedOptimizations ? { selectedOptimizations, changes } : {}),
}}
app={{
name: 'Contentful Optimization Next.js SDK SSR (Client)',
version: '0.1.0',
}}
defaults={{ consent: appConsent, persistenceConsent: appConsent }}
>
<GlobalLiveUpdatesProvider>
<PreviewPanel />
<Suspense>
<NextAppAutoPageTracker initialPageEvent={appConsent ? 'skip' : 'emit'} />
</Suspense>
<NextAppAutoPageTracker initialPageEvent={initialPageEvent} />
<div className="app-shell">
<nav>
<Link data-testid="link-home" href="/">
Expand Down
50 changes: 8 additions & 42 deletions implementations/nextjs-sdk_ssr/app/page-two/page.tsx
Original file line number Diff line number Diff line change
@@ -1,41 +1,16 @@
import { ControlPanel } from '@/components/ControlPanel'
import { CustomViewTracker } from '@/components/CustomViewTracker'
import { EntryCard } from '@/components/EntryCard'
import { appConfig } from '@/lib/config'
import { loadPageEntries } from '@/lib/contentful'
import { optimization } from '@/lib/optimization'
import { getAppConsent, toIdMap } from '@/lib/util'
import { NextjsOptimizationState } from '@contentful/optimization-nextjs/client'
import { getNextjsServerOptimizationData } from '@contentful/optimization-nextjs/server'
import { PAGES } from 'e2e-web'
import { cookies, headers } from 'next/headers'
import Link from 'next/link'

export default async function PageTwo() {
const [cookieStore, headerStore] = await Promise.all([cookies(), headers()])

const [entries, optimizationData] = await Promise.all([
loadPageEntries(PAGES.pageTwo.ids),
getAppConsent(cookieStore)
? getNextjsServerOptimizationData(optimization, {
consent: { events: true, persistence: true },
cookies: cookieStore,
headers: headerStore,
locale: appConfig.locale,
}).then(({ data }) => data)
: undefined,
const [serverState, [auto, manual]] = await Promise.all([
optimization.getServerState(),
optimization.getEntries([PAGES.pageTwo.auto, PAGES.pageTwo.manual]),
])

const entriesById = toIdMap(entries)
const autoEntry = entriesById.get(PAGES.pageTwo.auto)
const manualEntry = entriesById.get(PAGES.pageTwo.manual)
const autoResolved = autoEntry
? optimization.resolveOptimizedEntry(autoEntry, optimizationData?.selectedOptimizations)
: undefined
const manualResolved = manualEntry
? optimization.resolveOptimizedEntry(manualEntry, optimizationData?.selectedOptimizations)
: undefined

return (
<section data-testid="page-two-view">
<div className="page-header">
Expand All @@ -46,21 +21,16 @@ export default async function PageTwo() {
</div>

<CustomViewTracker componentId="page-two-hero" />
<ControlPanel demoCTA />
<NextjsOptimizationState data={optimizationData} />

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What was the reason you deleted this? Do you know what its purpose is?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure. was it probably not used? fist should check what it does.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This component is required to pass state from the SSR context to the client-side context, and it must be a component because client hooks are not allowed in server components. Please try to understand what you are doing in the course of "fixing" an issue.

@nalchevanidze David Nalchevanidze (nalchevanidze) Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

first of all, it was excluded by claude while fixing e2e tests and not having it did not broke anything. app functioned as it is(if it desired behavior that is required, lets add e2e test for it, and what it does).

after manual investigation: passing state is done by <OptimizationRoot defauls={}> and NextjsOptimizationState is just component to call hook hydrateOptimizationData -> await getRequiredBridge(sdk).hydrateOptimizationData(data) . interesting is what does it and why? why its not provider wraping whole app? and do we really need it if it does not affect apps functionality? and just naming does not give enough clarity to understand its necessity. if it ware provider to provide backend context. name would make perfect sence but then questions is why we have two places for that : OptimizationRoot.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Always happy to collaborate and discuss — I'd appreciate keeping the feedback constructive though.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Charles Hudson (@phobetron) David Nalchevanidze (@nalchevanidze) How can we make sure that we do not rely on knowing everything upfront? One would expect a test or a the compiler to let you know when you have broke something. If this is not needed for today's use case, my instinct would be to get rid of it.

Concretely, we need to invest in adding automated tests that ensure key functionality is not unintentionally changed. Claude will cover more ground than us as humans, and we need the safeguards to ensure we catch changes like that.

<ControlPanel demoCTA serverState={serverState} />

<div className="sections-grid sections-grid--split" data-testid="page-two-optimization">
<section className="page-section">
<header className="page-section__header">
<h2>Auto-observed</h2>
</header>
<div className="entry-grid">
{autoEntry && autoResolved ? (
<EntryCard
baselineEntry={autoEntry}
resolvedData={autoResolved}
manualTracking={false}
/>
{auto ? (
<EntryCard entry={auto} manualTracking={false} />
) : (
<p>Auto tracked entry is unavailable.</p>
)}
Expand All @@ -72,12 +42,8 @@ export default async function PageTwo() {
<h2>Manually-observed</h2>
</header>
<div className="entry-grid">
{manualEntry && manualResolved ? (
<EntryCard
baselineEntry={manualEntry}
resolvedData={manualResolved}
manualTracking={true}
/>
{manual ? (
<EntryCard entry={manual} manualTracking={true} />
) : (
<p>Manual tracked entry is unavailable.</p>
)}
Expand Down
94 changes: 21 additions & 73 deletions implementations/nextjs-sdk_ssr/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,48 +1,17 @@
import { ControlPanel } from '@/components/ControlPanel'
import { EntryCard } from '@/components/EntryCard'
import { LiveEntryCard } from '@/components/LiveEntryCard'
import { appConfig } from '@/lib/config'
import { type ContentEntry, loadPageEntries } from '@/lib/contentful'
import { optimization } from '@/lib/optimization'
import { getAppConsent, toIdMap } from '@/lib/util'
import { NextjsOptimizationState } from '@contentful/optimization-nextjs/client'
import {
type ServerTrackingResolvedData,
getNextjsServerOptimizationData,
} from '@contentful/optimization-nextjs/server'
import { CLICK_SCENARIOS, PAGES } from 'e2e-web'
import { cookies, headers } from 'next/headers'

export default async function Home() {
const [cookieStore, headerStore] = await Promise.all([cookies(), headers()])

const [entries, optimizationData] = await Promise.all([
loadPageEntries(PAGES.home.ids),
getAppConsent(cookieStore)
? getNextjsServerOptimizationData(optimization, {
consent: { events: true, persistence: true },
cookies: cookieStore,
headers: headerStore,
locale: appConfig.locale,
}).then(({ data }) => data)
: undefined,
const [serverState, liveUpdates, auto, manual] = await Promise.all([
optimization.getServerState(),
optimization.getEntry(PAGES.home.liveUpdates),
optimization.getEntries(PAGES.home.auto),
optimization.getEntries(PAGES.home.manual),
])

const entriesById = toIdMap(entries)
const resolvedById = new Map(
entries.map((entry) => [
entry.sys.id,
optimization.resolveOptimizedEntry(entry, optimizationData?.selectedOptimizations),
]),
)

const profile = optimizationData?.profile
const getMergeTagValue = (entry: unknown): string | undefined =>
optimization.getMergeTagValue(entry as never, profile)
const resolveEntry = (entry: ContentEntry): ServerTrackingResolvedData =>
optimization.resolveOptimizedEntry(entry, optimizationData?.selectedOptimizations)

const liveUpdatesEntry = entriesById.get(PAGES.home.liveUpdates)
const liveUpdatesEntry = liveUpdates?.baselineEntry

return (
<>
Expand All @@ -54,8 +23,7 @@ export default async function Home() {
</p>
</div>

<ControlPanel />
<NextjsOptimizationState data={optimizationData} />
<ControlPanel serverState={serverState} />

<section className="page-section" data-testid="live-updates-section">
<header className="page-section__header">
Expand All @@ -69,17 +37,17 @@ export default async function Home() {
<div className="sections-grid" data-testid="live-updates-examples">
<section data-testid="live-updates-default">
<h3>Default (inherits global setting)</h3>
<LiveEntryCard entry={liveUpdatesEntry} testId="live-default" />
<EntryCard entry={liveUpdatesEntry} testId="live-default" />
</section>

<section data-testid="live-updates-enabled">
<h3>Always On (liveUpdates=true)</h3>
<LiveEntryCard entry={liveUpdatesEntry} liveUpdates={true} testId="live-enabled" />
<EntryCard entry={liveUpdatesEntry} liveUpdates={true} testId="live-enabled" />
</section>

<section data-testid="live-updates-locked">
<h3>Locked (liveUpdates=false)</h3>
<LiveEntryCard entry={liveUpdatesEntry} liveUpdates={false} testId="live-locked" />
<EntryCard entry={liveUpdatesEntry} liveUpdates={false} testId="live-locked" />
</section>
</div>
) : (
Expand All @@ -93,22 +61,14 @@ export default async function Home() {
<h2>Auto Observed Entries</h2>
</header>
<div id="auto-observed" className="entry-grid">
{PAGES.home.auto.flatMap((id) => {
const entry = entriesById.get(id)
const resolvedData = resolvedById.get(id)
if (!entry || !resolvedData) return []
return [
<EntryCard
key={id}
baselineEntry={entry}
clickScenario={CLICK_SCENARIOS[id]}
getMergeTagValue={getMergeTagValue}
manualTracking={false}
resolveEntry={resolveEntry}
resolvedData={resolvedData}
/>,
]
})}
{auto.map((entry) => (
<EntryCard
key={entry.baselineEntry.sys.id}
entry={entry}
clickScenario={CLICK_SCENARIOS[entry.baselineEntry.sys.id]}
manualTracking={false}
/>
))}
</div>
</section>

Expand All @@ -117,21 +77,9 @@ export default async function Home() {
<h2>Manually Observed Entries</h2>
</header>
<div id="manually-observed" className="entry-grid">
{PAGES.home.manual.flatMap((id) => {
const entry = entriesById.get(id)
const resolvedData = resolvedById.get(id)
if (!entry || !resolvedData) return []
return [
<EntryCard
key={id}
baselineEntry={entry}
getMergeTagValue={getMergeTagValue}
manualTracking={true}
resolveEntry={resolveEntry}
resolvedData={resolvedData}
/>,
]
})}
{manual.map((entry) => (
<EntryCard key={entry.baselineEntry.sys.id} entry={entry} manualTracking={true} />
))}
</div>
</section>
</div>
Expand Down
28 changes: 12 additions & 16 deletions implementations/nextjs-sdk_ssr/components/ControlPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,21 @@

import { useGlobalLiveUpdatesControls } from '@/components/GlobalLiveUpdatesProvider'
import { appConfig } from '@/lib/config'
import { useConsent, useFlagSubscription } from '@/lib/hooks'
import {
useLiveUpdates,
useOptimization,
useOptimizationActions,
useProfileState,
useSelectedOptimizationsState,
} from '@contentful/optimization-nextjs/client'
import { type ControlPanelServerState, useControlPanel } from '@/lib/hooks'

import { useLiveUpdates } from '@contentful/optimization-nextjs/client'
import type { JSX } from 'react'

export function ControlPanel({ demoCTA }: { readonly demoCTA?: boolean } = {}): JSX.Element {
const sdk = useOptimization()
const { identify, reset } = useOptimizationActions()
const { consent, setConsent } = useConsent()
const profile = useProfileState()
const selectedOptimizations = useSelectedOptimizationsState()
interface ControlPanelProps {
readonly demoCTA?: boolean
readonly serverState?: ControlPanelServerState
}

export function ControlPanel({ demoCTA, serverState }: ControlPanelProps = {}): JSX.Element {
const { globalLiveUpdates, onToggleGlobalLiveUpdates } = useGlobalLiveUpdatesControls()
const { previewPanelVisible, setPreviewPanelVisible } = useLiveUpdates()
const booleanFlag = useFlagSubscription('boolean')
const { sdk, identify, reset, consent, setConsent, profile, activeCount, booleanFlag } =
useControlPanel(serverState)
const isIdentified = Boolean(profile?.traits.identified)

return (
Expand Down Expand Up @@ -153,7 +149,7 @@ export function ControlPanel({ demoCTA }: { readonly demoCTA?: boolean } = {}):
Active optimizations
</span>
<span className="control-panel__row-value" data-testid="selected-optimizations-count">
{selectedOptimizations?.length ?? 0}
{activeCount}
</span>
<span />
</div>
Expand Down
29 changes: 29 additions & 0 deletions implementations/nextjs-sdk_ssr/components/EntryCard.client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
'use client'

import type { ContentEntry } from '@/lib/contentful'
import { OptimizedEntry } from '@contentful/optimization-nextjs/client'
import type { JSX } from 'react'

interface EntryCardClientProps {
entry: ContentEntry
liveUpdates?: boolean
testId?: string
}

export function EntryCardClient({ entry, liveUpdates, testId }: EntryCardClientProps): JSX.Element {
return (
<OptimizedEntry baselineEntry={entry} liveUpdates={liveUpdates}>
{({ fields, sys: { id } }) => (
<div data-test-entry-id={id} data-testid={`content-${testId}`}>
<div
aria-label={`${fields.text ?? ''} [Entry: ${id}]`}
data-testid={`entry-text-${testId}`}
>
<p>{String(fields.text ?? '')}</p>
<p>{`[Entry: ${id}]`}</p>
</div>
</div>
)}
</OptimizedEntry>
)
}
Loading
Loading