Skip to main content

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.

Public beta

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.

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").

Forward compatibility

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

EmitRequired?FromNotes
<html lang="…">OptionalpageMetadata.localeSingle source for the document language
<title>, description, canonical, OG, …OptionalpageMetadata.*Keep the name / property / httpEquiv discriminator on custom tags
Author <head> markupOptionalpageMetadata.customHeadHtmlVerbatim. May contain <script> — emitting it runs those scripts
WebfontsYesassets[] where type: "webfont"First, so faces register before paint. External → <link>, inline → <style>
Component stylesYesdeliveryModes.ssr.styles[]In array orderreset.css before components.css
Per-experience stylesYesassets[] where type: "style"After component styles, in array order, so they win the cascade
The experience bodyYesassets[] where type: "html-body"Raw / unescaped — see Security
Author <body> markupOptionaldisplayMetadata.customBodyHtmlVerbatim. May contain <script>
<script src="flex-ssr.js">YesdeliveryModes.ssr.scripts[]Honour module and loadStrategy; add integrity + crossorigin
data-flex-manifest-url="…" on the wrapperYesThe manifest URL you servedCarries 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 200 directly; a redirect could bounce an allowed URL at an internal target.
  • Set a timeout.
  • Require 2xx and valid JSON.
  • Shape-check it — the decoded JSON must carry at least one of schemaVersion, deliveryModes, or experience. 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:

  1. Guards against being loaded twice.
  2. Registers the experience's custom elements.
  3. 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.
  4. 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.
  5. Sets data-flex-ready="true" on each viewer when it's live.
  6. Watches the document for viewers added or removed later, so single-page apps that swap regions work without extra wiring.
  7. Never throws into your page. Failures are logged to the console with a [flex-ssr] prefix; window.onerror is 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:

  1. The parameter name is cer_ + manifest.experience.slug.
  2. If that's absent and experience.accountSlug is set, also check cer_<accountSlug>__<experienceSlug> — the collision fallback, used when two embeds on one page share an experience slug across accounts.
  3. The value is the target page slug. Resolve it against pages[].
  4. If it names the page this manifest already describes (experience.pageSlug, or the entry with current: true), use the manifest you have — no refetch.
  5. Otherwise fetch that page's manifestUrl (with the same safety checks) and render its html-body.
  6. 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.

Cache key

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.

A small race

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.
  • schemaVersion gate — render "1", refuse the rest.
  • Pass through integrity and add crossorigin="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:

  1. baseUrl must be an absolute http(s) URL with no query string or fragment. An invalid value returns 400.

  2. Download each from and serve its bytes at <baseUrl>/<path>. Dedupe by from — shared bundles only need fetching once.

  3. Serve the returned manifest as-is. URLs embedded inside inline content — <img src>, srcset, CSS url(…), inline @font-face — have already been rewritten for you.

  4. Check the X-Manifest-Rewrite response header:

    ValueMeaning
    rewrittenAssets relocated. Mirror assetRewrites and serve the manifest as-is.
    noopNothing to relocate. Safe to serve as-is; assetRewrites is 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 (&lt;ceros-experience-viewer&gt;), 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.php and includes/flex-manifest.php are the two files to read.