Flex SSR with Next.js
A working Flex SSR integration for the Next.js App Router, built as an async server component. It covers the parts that aren't obvious the first time: resolving deep links, stopping the experience's CSS from repainting your site, and keeping the HTML payload from doubling.
Read Flex SSR first — this page assumes the manifest and the emit order.
Types
Model only what you use. Every field below is on the real manifest.
// types/ceros.ts
export interface CerosManifest {
schemaVersion: string
publishedAt: string
experience: {
slug: string
accountSlug: string
pageSlug: string
pageNumber: number
}
pageMetadata?: {
title?: string
description?: string
canonicalUrl?: string
locale?: string
openGraph?: Record<string, unknown>
twitter?: Record<string, unknown>
customMetaTags?: {
name?: string
property?: string
httpEquiv?: string
content: string
}[]
customHeadHtml?: string
noScriptHtml?: string
}
displayMetadata?: {
mode?: 'scale' | 'fluid'
designViewport?: { width: number | string; height: number | string }
customBodyHtml?: string
}
deliveryModes: Record<
string,
{
scripts: {
url: string
integrity?: string
module?: boolean
loadStrategy?: string
}[]
styles: { url: string; integrity?: string }[]
}
>
assets: {
type: 'html-body' | 'style' | 'webfont' | 'script'
name?: string
src?: {
type?: 'inline' | 'external'
url?: string
content?: string
integrity?: string
}
}[]
pages?: {
slug: string
label?: string
manifestUrl?: string
isFirst?: boolean
current?: boolean
}[]
}
The safe fetch
The manifest URL is configuration, but treat it as untrusted — this function
calls fetch, so it's your SSRF boundary.
// lib/ceros.ts
import type { CerosManifest } from '@/types/ceros'
const ALLOWED_HOSTS = ['ceros.site', 'cerosstage.site', 'cerosdev.site']
const MANIFEST_FILE = 'manifest.v1.json'
const MANIFEST_PATTERN = /manifest(\.v[0-9.]+)?\.json$/
/** Only https, only Ceros hosts (apex or any subdomain). */
export function isAllowedFlexUrl(url: string): boolean {
let parsed: URL
try {
parsed = new URL(url.trim())
} catch {
return false
}
if (parsed.protocol !== 'https:') return false
const host = parsed.hostname.toLowerCase()
return ALLOWED_HOSTS.some((d) => host === d || host.endsWith(`.${d}`))
}
/** Accept either an experience URL or a full manifest URL. */
export function normalizeManifestUrl(url: string): string {
let out = url.trim()
if (!MANIFEST_PATTERN.test(out)) {
if (!out.endsWith('/')) out += '/'
out += MANIFEST_FILE
}
return out
}
const REVALIDATE = process.env.VERCEL_ENV === 'production' ? 3600 : 10
export async function fetchManifest(
url: string,
): Promise<CerosManifest | null> {
const manifestUrl = normalizeManifestUrl(url)
if (!isAllowedFlexUrl(manifestUrl)) {
console.error(`Refusing to fetch a Ceros manifest from ${manifestUrl}`)
return null
}
try {
const res = await fetch(manifestUrl, {
headers: { Accept: 'application/json' },
redirect: 'error', // never follow redirects
next: { revalidate: REVALIDATE, tags: ['ceros-flex'] },
})
if (!res.ok) {
console.error(
`Ceros manifest ${manifestUrl}: ${res.status} ${res.statusText}`,
)
return null
}
const manifest = (await res.json()) as CerosManifest
if (manifest.schemaVersion !== '1') {
console.error(
`Unsupported manifest schemaVersion: ${manifest.schemaVersion}`,
)
return null
}
return manifest
} catch (e) {
console.error(`Ceros manifest ${manifestUrl} failed:`, e)
return null
}
}
next: { tags: ['ceros-flex'] } lets you flush every manifest at once from a
webhook or an admin action with revalidateTag('ceros-flex'), rather than
waiting out the window.
Deep-link resolution
// lib/ceros.ts (continued)
export const DEEP_LINK_PREFIX = 'cer_'
function first(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value
}
/** Read `?cer_<expSlug>=<pageSlug>`, with the account-scoped collision fallback. */
export function requestedPageSlug(
manifest: CerosManifest,
searchParams: Record<string, string | string[] | undefined>,
): string | undefined {
const { slug, accountSlug } = manifest.experience
if (!slug) return undefined
return (
first(searchParams[`${DEEP_LINK_PREFIX}${slug}`]) ??
(accountSlug
? first(searchParams[`${DEEP_LINK_PREFIX}${accountSlug}__${slug}`])
: undefined)
)
}
/** The page to render: the requested one, else the first. */
export function resolveTargetPage(manifest: CerosManifest, pageSlug?: string) {
const pages = manifest.pages ?? []
if (pages.length === 0) return null
const firstPage =
pages.find((p) => p.isFirst) ?? pages.find((p) => p.current) ?? pages[0]
if (!pageSlug) return firstPage
return pages.find((p) => p.slug === pageSlug) ?? firstPage
}
/* app/ceros-flex.css */
.ceros-flex__content #experience-canvas-container {
overflow: visible !important;
height: auto !important;
}
The component
// components/CerosFlex.tsx
import {
fetchManifest,
fetchScopedStyles,
normalizeManifestUrl,
requestedPageSlug,
resolveTargetPage,
scopeInlineStyle,
} from '@/lib/ceros'
import './ceros-flex.css'
type SearchParams = Record<string, string | string[] | undefined>
export default async function CerosFlex({
url,
searchParams,
}: {
url: string
searchParams?: SearchParams
}) {
const rootManifest = await fetchManifest(url)
if (!rootManifest) return null // degrade — render nothing, or an iframe fallback
// Follow the deep link, if it points at a different page than this manifest.
let manifest = rootManifest
let manifestUrl = normalizeManifestUrl(url)
const target = resolveTargetPage(
rootManifest,
requestedPageSlug(rootManifest, searchParams ?? {}),
)
if (target?.manifestUrl && target.slug !== rootManifest.experience.pageSlug) {
const pageManifest = await fetchManifest(target.manifestUrl)
if (pageManifest) {
manifest = pageManifest
manifestUrl = normalizeManifestUrl(target.manifestUrl)
}
}
const ssr = manifest.deliveryModes?.ssr
const bodyHtml =
manifest.assets.find((a) => a.type === 'html-body')?.src?.content ?? ''
// Webfonts stay global; component + per-experience styles get scoped.
const webfonts = manifest.assets.filter((a) => a.type === 'webfont')
const scopedComponentStyles = await fetchScopedStyles(
(ssr?.styles ?? []).map((s) => s.url).filter(Boolean),
)
const scopedExperienceStyles = manifest.assets
.filter((a) => a.type === 'style' && a.src?.content)
.map((a) => scopeInlineStyle(a.src!.content!))
if (!bodyHtml && scopedComponentStyles.length === 0) return null
return (
<>
{/* 1. Webfonts first — global, never scoped. */}
{webfonts.map((font, i) =>
font.src?.url ? (
<link
key={`font-${i}`}
rel="stylesheet"
href={font.src.url}
integrity={font.src.integrity}
crossOrigin="anonymous"
/>
) : (
<style
key={`font-${i}`}
dangerouslySetInnerHTML={{ __html: font.src?.content ?? '' }}
/>
),
)}
{/* 2. Component styles (reset.css, components.css) — in array order. */}
{scopedComponentStyles.map((css, i) => (
<style key={`ssr-${i}`} dangerouslySetInnerHTML={{ __html: css }} />
))}
{/* 3. Per-experience styles last, so brand tokens win. */}
{scopedExperienceStyles.map((css, i) => (
<style key={`brand-${i}`} dangerouslySetInnerHTML={{ __html: css }} />
))}
{/* 4. The experience body, in a wrapper carrying the manifest URL. */}
<div
className="ceros-flex__content"
data-flex-manifest-url={manifestUrl}
suppressHydrationWarning
dangerouslySetInnerHTML={{ __html: bodyHtml }}
/>
{/* 5. The hydration runtime. */}
{(ssr?.scripts ?? []).map((script, i) => (
<script
key={`ssr-script-${i}`}
src={script.url}
type={script.module ? 'module' : undefined}
integrity={script.integrity}
crossOrigin={script.integrity ? 'anonymous' : undefined}
defer={script.loadStrategy !== 'async'}
async={script.loadStrategy === 'async'}
/>
))}
</>
)
}
Use it from a page, passing searchParams through so deep links resolve
server-side:
// app/showcase/page.tsx
import CerosFlex from '@/components/CerosFlex'
export default async function Page({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
return (
<main>
<h1>Spring Launch</h1>
<CerosFlex
url={process.env.FLEX_URL_SHOWCASE!}
searchParams={await searchParams}
/>
</main>
)
}
Keeping the manifest URL in an environment variable means republishing or swapping an experience is a config change rather than a deploy.
Page metadata
Emit <head> from generateMetadata so the manifest drives your SEO and social
tags:
// app/showcase/page.tsx (continued)
import type { Metadata } from 'next'
import { fetchManifest } from '@/lib/ceros'
export async function generateMetadata(): Promise<Metadata> {
const manifest = await fetchManifest(process.env.FLEX_URL_SHOWCASE!)
const meta = manifest?.pageMetadata
if (!meta) return {}
return {
title: meta.title,
description: meta.description,
alternates: meta.canonicalUrl
? { canonical: meta.canonicalUrl }
: undefined,
openGraph: meta.openGraph as Metadata['openGraph'],
twitter: meta.twitter as Metadata['twitter'],
other: Object.fromEntries(
(meta.customMetaTags ?? [])
.filter((t) => t.name)
.map((t) => [t.name!, t.content]),
),
}
}
Both calls hit the same cached fetch, so this costs nothing extra.
customHeadHtml and customBodyHtml may contain the author's own <script>
tags. generateMetadata can't emit raw HTML, so if your experiences rely on
custom head scripts you'll need to inject them another way — a <script> in
the component tree, or your root layout. If you skip them, they don't run.
Keeping the payload small
The experience body is large — tens or hundreds of kilobytes of markup. Rendered
through a server component with dangerouslySetInnerHTML, Next.js serialises
it into the document twice: once as DOM, and once in the inlined RSC flight
payload used for hydration. On a real site this made the experience roughly 63%
of the home page's bytes.
This is structural to the App Router — server-component output always enters the flight payload. If the doubling matters for your page weight, the fix is to keep the body out of React and splice it in at the response layer:
- Render the container empty (
dangerouslySetInnerHTML={{ __html: '' }}), keepingdata-flex-manifest-urlon it plus a marker attribute. - In
middleware.ts, rewrite document requests for Flex-embedding routes to a route handler, passing the original path in a request header. - In that route handler, re-request the page internally, then replace each
empty placeholder with its manifest's
html-body. - Return the assembled HTML with a
Cache-Controlheader so your CDN caches it under the original URL.
The flight payload then carries an empty string, the raw HTML carries the body
once, and hydration leaves the container alone because its (empty)
dangerouslySetInnerHTML is unchanged.
Three details that matter if you build this:
- Put the original path in a request header, not a query parameter — rewrite query params merge into the visible URL on some hosts and can loop through preview-auth redirects.
- Forward the original request headers on the internal re-request, so auth cookies survive behind deployment protection.
- Do the transform in a route handler, not middleware — middleware responses aren't CDN-cacheable.
Fail open: if anything in the assembly path errors, serve the page un-deduped rather than failing the request. It's an optimisation, not a correctness requirement — start with the simple component above and add this only if page weight is a real problem.
Checklist
- HTTPS-only, Ceros-host-only, no-redirect manifest fetch
-
schemaVersion === '1'gate -
searchParamsthreaded through for deep links, with thecer_parameter in your cache key - Webfonts emitted unscoped, component and per-experience styles scoped to your container
- Emit order: webfonts → component styles → per-experience styles → body → runtime
-
data-flex-manifest-urlon the wrapper, for in-place page navigation -
suppressHydrationWarningon the container -
integrity+crossOriginpassed through on external assets - A graceful fallback when the fetch fails