Flex SSR
Your server fetches a JSON document describing the experience, writes the experience's pre-rendered HTML straight into its own response, and points the browser at one hydration script. The visitor sees the experience in the first byte of HTML — no iframe, no shadow root, no client-side fetch.
This is the mode to pick when you want the experience indexed, in your initial
HTML payload, and inside your own <head>, structured data, and caching rules.
It's also the only mode where your server is part of the render path, so it's
the most work.
Flex SSR is in public beta.
The shape of it
Every published Flex page produces a public, anonymous JSON manifest at a predictable URL:
https://acme.ceros.site/spring-launch/pricing/manifest.v1.json
The manifest carries the page's pre-rendered HTML body, the stylesheets and
webfonts that body needs, the page's <head> metadata, and the URL of the
flex-ssr.js hydration runtime. Your job is to splice those pieces into your
own HTML response.
BUILD TIME or PER REQUEST
─────────────────────────
your server ──── GET .../manifest.v1.json ────▶ Ceros CDN
◀─── manifest JSON ─────────────────
your server compose the response:
<head> ← pageMetadata
styles ← webfonts, then deliveryModes.ssr.styles, then style assets
<body> ← the html-body asset, verbatim
<script src="flex-ssr.js">
ON REQUEST
──────────
browser ──── GET /your-page ─────────────▶ your server
◀─── complete HTML, experience included ───
browser ──── GET flex-ssr.js, CSS, fonts ─▶ Ceros CDN
browser flex-ssr.js finds <ceros-experience-viewer>,
hydrates it, starts interactions,
sets data-flex-ready="true"
The manifest is served with permissive CORS headers and long edge caching. It is public and anonymous — no API key needed to read it.
Finding the manifest URL
Three ways, in order of preference.
1. Read the x-flex-manifest header (recommended)
Every published experience page response carries an x-flex-manifest
header whose value is the authoritative manifest URL for that page. If all you
have is a public experience URL — because someone pasted one into a CMS field,
say — issue a cheap HEAD and read the header:
HEAD /spring-launch/pricing HTTP/1.1
Host: acme.ceros.site
→ 200
x-flex-manifest: https://acme.ceros.site/spring-launch/pricing/manifest.v1.json
access-control-expose-headers: x-flex-manifest
Why this rather than string-building the URL yourself:
- The value is always the canonical Ceros host (
*.ceros.site), derived from the account — never the host that was requested. So it stays correct behind a vanity domain, and a URL someone pasted can't redirect you at a host you didn't intend to fetch from. - It's page-targeted. Request a specific page and you get that page's manifest; request the experience root and you get the first page's.
The header appears only on successful published-experience page responses — not on 404s, not on the manifest URL itself, not on the oEmbed route. If it isn't there, fall back to one of the options below.
2. Append the filename
A public experience URL plus /manifest.v1.json resolves to that page's
manifest. The experience root resolves to page one:
https://acme.ceros.site/spring-launch/ → first page's manifest
https://acme.ceros.site/spring-launch/pricing/ → the pricing page's manifest
3. Fetch it from the API
The /experiences/{resourceId}/embed-codes endpoint returns embed information
for every delivery mode, including the manifest URL. Use this when you're
building an authoring UI where people pick an experience from a list rather
than pasting a URL.
Whichever route you take, persist the resolved manifest URL on your content model — a block attribute, a component property, a CMS field. Rendering shouldn't have to re-derive it.
Anatomy of the manifest
Trimmed to the parts an SSR integration touches:
{
"schemaVersion": "1", // refuse to render anything else
"publishedAt": "2026-06-18T12:00:00Z",
"experience": {
"slug": "spring-launch", // → deep-link param name: cer_spring-launch
"accountSlug": "acme",
"pageSlug": "pricing", // the page THIS manifest describes
"pageNumber": 2,
},
"pageMetadata": {
// everything that belongs in <head>
"title": "Spring Launch — Pricing",
"description": "…",
"canonicalUrl": "https://acme.ceros.site/spring-launch/pricing/",
"locale": "en-US", // → <html lang="en-US">
"keywords": ["…"],
"robots": "index,follow",
"favicon": { "url": "…", "mimeType": "image/png", "sizes": "32x32" },
"openGraph": { "title": "…", "image": { "url": "…" } },
"twitter": { "card": "summary_large_image", "title": "…" },
"customMetaTags": [
// keep the name/property/httpEquiv discriminator
{ "property": "og:see_also", "content": "…" },
],
"customHeadHtml": "…", // author markup, emitted verbatim — MAY contain <script>
"noScriptHtml": "<noscript>…</noscript>",
},
"displayMetadata": {
"mode": "scale", // "scale" | "fluid"
"designViewport": { "width": 1280, "height": 720 },
"customBodyHtml": "…", // author markup, emitted verbatim — MAY contain <script>
},
"deliveryModes": {
"ssr": {
"styles": [
// EMIT as <link rel=stylesheet>, in this order
{
"url": ".../reset.css",
"integrity": "sha384-…",
"mimeType": "text/css",
},
{
"url": ".../components.css",
"integrity": "sha384-…",
"mimeType": "text/css",
},
],
"scripts": [
// EMIT — the hydration runtime
{
"url": ".../flex-ssr.js",
"integrity": "sha384-…",
"module": true,
"loadStrategy": "defer",
},
],
},
// "inline" and "iframe" also appear here — ignore them for SSR
},
"assets": [
{
"type": "html-body", // the pre-rendered experience markup
"name": "body",
"src": {
"type": "inline",
"content": "<ceros-experience-viewer>…</ceros-experience-viewer>",
},
},
{
"type": "webfont",
"name": "Inter",
"src": { "type": "external", "url": "https://fonts.googleapis.com/…" },
},
{
"type": "webfont",
"name": "Brand",
"src": { "type": "inline", "content": "@font-face{…}" },
},
{
"type": "style", // brand-kit / local style tokens
"name": "brand-kit",
"src": {
"type": "external",
"url": ".../brand.css",
"integrity": "sha384-…",
},
},
],
"media": [
/* inventory of images / video — informational, ignore for rendering */
],
"pages": [
// sibling-page index (multi-page experiences)
{
"slug": "hero",
"label": "Hero",
"manifestUrl": ".../hero/manifest.v1.json",
"isFirst": true,
"current": false,
},
{
"slug": "pricing",
"label": "Pricing",
"manifestUrl": ".../pricing/manifest.v1.json",
"isFirst": false,
"current": true,
},
],
}
Every asset's src is one of two shapes: { "type": "inline", "content": "…" }
(output the content verbatim) or { "type": "external", "url": "…" } (emit a
<link> or <script src>, passing through integrity and adding
crossorigin="anonymous").
Ignore top-level fields and enum values you don't recognise — the manifest grows
additively within a schemaVersion. Do not render a manifest whose
schemaVersion isn't "1".
What your server emits
| Emit | Required? | From | Notes |
|---|---|---|---|
<html lang="…"> | Optional | pageMetadata.locale | Single source for the document language |
<title>, description, canonical, OG, … | Optional | pageMetadata.* | Keep the name / property / httpEquiv discriminator on custom tags |
Author <head> markup | Optional | pageMetadata.customHeadHtml | Verbatim. May contain <script> — emitting it runs those scripts |
| Webfonts | Yes | assets[] where type: "webfont" | First, so faces register before paint. External → <link>, inline → <style> |
| Component styles | Yes | deliveryModes.ssr.styles[] | In array order — reset.css before components.css |
| Per-experience styles | Yes | assets[] where type: "style" | After component styles, in array order, so they win the cascade |
| The experience body | Yes | assets[] where type: "html-body" | Raw / unescaped — see Security |
Author <body> markup | Optional | displayMetadata.customBodyHtml | Verbatim. May contain <script> |
<script src="flex-ssr.js"> | Yes | deliveryModes.ssr.scripts[] | Honour module and loadStrategy; add integrity + crossorigin |
data-flex-manifest-url="…" on the wrapper | Yes | The manifest URL you served | Carries scoped scripts, and opts into in-place page navigation. Emit it always — an integration can't tell whether an experience uses either |
Ordering is load-bearing. Webfonts, then component styles, then per-experience styles. Get it wrong and you'll see a flash of unstyled text, or brand colours losing to component defaults.
Where author scripts live
If the experience's author attached custom HTML in Flex, their <script> tags
ride inline inside pageMetadata.customHeadHtml and
displayMetadata.customBodyHtml. Emitting those strings is what runs them.
There is no separate script asset to inject. The manifest contract reserves an
assets[] entry of type: "script", but the publish pipeline doesn't emit one
today — that array is empty, and you don't need to handle it.
So: if author head/body scripts matter for your integration, emit
customHeadHtml and customBodyHtml. If you skip them, those scripts don't
run.
The recipe
1. Get the manifest URL
See Finding the manifest URL. Store it on your content model.
2. Fetch the manifest, safely
The URL is often author-supplied, and you're about to output its contents verbatim. Enforce all of:
- HTTPS only. Reject any other scheme.
- Publicly routable host. Reject private, loopback, and link-local ranges.
- Don't follow redirects. A published manifest is served
200directly; a redirect could bounce an allowed URL at an internal target. - Set a timeout.
- Require
2xxand valid JSON. - Shape-check it — the decoded JSON must carry at least one of
schemaVersion,deliveryModes, orexperience. Arbitrary JSON isn't a manifest. - Gate on
schemaVersion === "1".
Fetch fresh — manifests change when the author republishes. See Caching for how often.
3. Resolve which page to render
Read the deep-link query parameter off the incoming request. See Multi-page experiences.
4. Compose <head> (optional but valuable)
Title, description, canonical, OpenGraph, Twitter, customMetaTags,
customHeadHtml, and <html lang> from locale. This is what buys the SEO,
social, and no-flash wins.
If your integration only controls a fragment of the page — most CMS blocks do — you can skip this entirely and the experience still renders and hydrates correctly. Skip it deliberately, and tell your users you did.
5. Emit styles, in order
Webfonts → deliveryModes.ssr.styles[] → assets[] of type: "style".
6. Emit the body
The html-body asset's src.content, raw, inside a wrapper element:
<div
class="my-flex-embed"
data-flex-manifest-url="https://acme.ceros.site/spring-launch/pricing/manifest.v1.json"
>
<!-- html-body content, verbatim -->
</div>
Do not run it through an HTML sanitiser or allow-list. The body is arbitrary
SVG and custom-element markup; a filter like WordPress' wp_kses will corrupt
it. Your safety comes from the validated fetch in step 2.
7. Emit the hydration runtime
<script
src="https://assets.ceros.site/js/…/flex-ssr.js"
type="module"
defer
integrity="sha384-…"
crossorigin="anonymous"
></script>
Take the URL, integrity, module, and loadStrategy from
deliveryModes.ssr.scripts[] rather than hard-coding them — the URL is
release-pinned and the SRI hash moves with it.
8. Fall back gracefully
If the manifest fetch fails, degrade to an iframe embed or the Flex Inline snippet rather than erroring the page. Both shipping Ceros integrations do this.
What the hydration runtime does
When flex-ssr.js loads, it:
- Guards against being loaded twice.
- Registers the experience's custom elements.
- Configures hydration: the experience lives in the regular page tree (not a shadow root) and scales to its wrapper element, not the viewport. Responsive variants key off the experience's own width, so an experience in a narrow column behaves like a narrow viewport regardless of the window size.
- Finds every
<ceros-experience-viewer>in the document and hydrates each one, scoped to its immediate parent. Multiple experiences per page each get their own interactions. - Sets
data-flex-ready="true"on each viewer when it's live. - Watches the document for viewers added or removed later, so single-page apps that swap regions work without extra wiring.
- Never throws into your page. Failures are logged to the console with a
[flex-ssr]prefix;window.onerroris not invoked.
If you need to sequence your own code on hydration, wait for
data-flex-ready="true".
Multi-page experiences
A multi-page experience publishes one manifest per page, and pages[] on
any manifest indexes them all. Two things to handle.
Serving the right page
Links between pages render as query parameters on your URL:
?cer_<experienceSlug>=<pageSlug>
For example https://acme.com/showcase?cer_spring-launch=pricing. This is
deliberate — the experience is one component on a page it doesn't own, so
middle-click, cmd-click, and "Copy link address" must resolve against your URL,
not Ceros'.
Your server reads that same parameter to decide which page to render:
- The parameter name is
cer_+manifest.experience.slug. - If that's absent and
experience.accountSlugis set, also checkcer_<accountSlug>__<experienceSlug>— the collision fallback, used when two embeds on one page share an experience slug across accounts. - The value is the target page slug. Resolve it against
pages[]. - If it names the page this manifest already describes (
experience.pageSlug, or the entry withcurrent: true), use the manifest you have — no refetch. - Otherwise fetch that page's
manifestUrl(with the same safety checks) and render itshtml-body. - If the parameter is absent, unmatched, or its fetch fails, fall back to the
page with
isFirst: true.
Sanitise the value before use.
Getting this right is what makes a pasted deep link render the correct page in the first byte of HTML, instead of always emitting page one and waiting for the browser to swap.
If you cache rendered HTML, the deep-link parameter must be part of the
cache key — otherwise ?cer_spring-launch=pricing and the bare URL collapse into
one cached page. Next.js keys the full URL by default; a custom CDN may need
Vary or cache-key configuration.
One parameter per embedded experience. Two experiences on a page resolve
independently, each from its own experience.slug.
In-place page swaps
By default, clicking an internal page link navigates your whole page to the
deep-link URL. To get SPA-style swaps instead, put
data-flex-manifest-url="<the manifest URL you served>" on the wrapper element
around the html-body.
When it's present, flex-ssr.js fetches that manifest after bootstrap, wires a
router scoped to that wrapper, intercepts left-clicks on internal links, and
sets data-flex-router-ready="true" on the wrapper once the router is live.
data-flex-ready is set synchronously at bootstrap, but router wiring is async —
it fetches the manifest first. A click in that gap falls through to a full page
navigation, which still lands on the right page. If you're writing tests that
need the router before interacting, wait for data-flex-router-ready="true"
rather than data-flex-ready.
Security
You're outputting a remote document's contents verbatim into your own page. The whole trust posture rests on the fetch:
- HTTPS only, no exceptions.
- Public-host guard against private, loopback, and link-local addresses. Treat the manifest URL as untrusted input — it usually is.
- No redirect following.
- Shape check before treating JSON as a manifest.
schemaVersiongate — render"1", refuse the rest.- Pass through
integrityand addcrossorigin="anonymous"on external<link>and<script>tags, so a CDN compromise can't silently swap the bytes. - Don't sanitise the body — see step 6. Sanitising breaks the experience without making you safer; the validated fetch is the control that matters.
If you also localise assets, restrict downloads to Ceros-owned hosts
(*.ceros.site, *.ceros.com) over HTTPS, behind the same public-host guard.
Caching
Manifests are edge-cached with a long TTL and stale-while-revalidate, and a
republish purges them. Nothing pushes a notification to your server when an
author republishes, so your cache window decides how soon a change appears.
A reasonable default: revalidate the manifest every few minutes in production,
seconds in preview environments. Then cache your assembled HTML at your own CDN
on top of that, keyed on the full URL including any cer_* parameter.
Self-hosting Ceros assets
Fetching per render is simple and correct, but it leaves a runtime dependency on
the Ceros CDN. If you need to eliminate that — for an air-gapped environment, or
because you mirror all third-party assets — request the manifest with a
baseUrl parameter:
GET .../manifest.v1.json?baseUrl=https://acme.com/ceros-assets
You get back a manifest whose Ceros-owned asset URLs already point at your
base, plus an additive assetRewrites map telling you exactly what to mirror:
{
// …all the normal fields, with Ceros asset URLs already rewritten…
"assetRewrites": {
"baseUrl": "https://acme.com/ceros-assets",
"assets": [
{
"from": "https://media.cdn.ceros.site/a/b/img.png", // download this
"path": "media.cdn.ceros.site/a/b/img.png", // serve at <baseUrl>/<path>
"to": "https://acme.com/ceros-assets/media.cdn.ceros.site/a/b/img.png",
},
],
},
}
The contract:
-
baseUrlmust be an absolutehttp(s)URL with no query string or fragment. An invalid value returns400. -
Download each
fromand serve its bytes at<baseUrl>/<path>. Dedupe byfrom— shared bundles only need fetching once. -
Serve the returned manifest as-is. URLs embedded inside inline content —
<img src>,srcset, CSSurl(…), inline@font-face— have already been rewritten for you. -
Check the
X-Manifest-Rewriteresponse header:Value Meaning rewrittenAssets relocated. Mirror assetRewritesand serve the manifest as-is.noopNothing to relocate. Safe to serve as-is; assetRewritesis omitted.skippedThe rewrite couldn't run. The body may still reference the Ceros CDN — treat it as not localised and fail loudly.
Third-party hosts (Google Fonts, author-added scripts) are deliberately left untouched; handle those yourself if you need to.
pages[].manifestUrl gets the same ?baseUrl= appended, so offline page
navigation keeps receiving rewritten sibling manifests.
Troubleshooting
Nothing renders, and the console shows nothing. Check the body actually made
it into your HTML — view source and look for <ceros-experience-viewer>. If
it's escaped (<ceros-experience-viewer>), your templating engine is
escaping it; emit it raw.
The experience renders but looks unstyled or wrong. Check style ordering:
webfonts, then deliveryModes.ssr.styles[] in array order, then style assets.
components.css after reset.css, brand styles after both.
Fonts flash or fall back. Webfont assets need to come first, before the component styles.
The experience's CSS is repainting your navigation or footer. The SSR styles
are global by design — the experience lives in your page tree, not a shadow
root. Either scope the component stylesheets to your container (fetch them and
wrap in @scope, as the Next.js recipe does) or use
Flex Inline, where isolation is built in.
It's stuck on page one despite a ?cer_… parameter. Confirm the parameter
name is cer_ + experience.slug exactly, and that your cache key includes the
query string.
Console shows [flex-ssr] errors. The runtime logs and continues rather than
throwing. The message names what failed to bootstrap.
Hydration warnings in React. The body is injected outside React's
reconciliation on purpose. Mark the container suppressHydrationWarning — see
the Next.js recipe.
Analytics
The experience dispatches its analytics events onto your page once
flex-ssr.js has hydrated — same events, same payloads as the other modes. See
Analytics events.
Scripting the experience
Use the Flex Experience SDK. On SSR the experience is in
the regular page tree, so the default connect() works, as does passing a
container:
<script type="module">
import { connect } from 'https://assets.ceros.site/js/flex-experience-sdk.js'
const experience = await connect()
experience.findByLocator('hero-video').media.play()
</script>
Page swaps are in-place, so re-connect on the flex.page.change window event —
see
the SDK's delivery modes page.
Worked examples
- Next.js App Router — a complete server component, including deep-link resolution, CSS scoping, and payload de-duplication.
- Ceros AEM Connector — Adobe Experience Manager (Java / OSGi / Sling). The most complete integration: its Fetch mode is Flex SSR, and Store and HTML Import are SSR served from locally mirrored assets.
- Ceros WordPress plugin —
PHP / Gutenberg block. The clearest worked example of porting the contract to
a new platform;
includes/flex-ssr-renderer.phpandincludes/flex-manifest.phpare the two files to read.