Docs
Server-Side Rendering

Server-Side Rendering

Apollion is engineered for the server on purpose. Deterministic componentIds, a styled-components SSR path validated by real SSG consumers, and a CSS-vars surface for HTML that ships no React at all — this page is the map of that story.

Deterministic class names

The published lib/ bakes a deterministic componentId into every styled component at build time (an esbuild onLoad plugin running the babel-plugin-styled-components transform with ssr: true + fileName: true). Server and client always agree on class names — zero hydration mismatch by construction, regardless of chunk evaluation order.

Since v5.2.0 the published artifact also sets displayName: false: shipped classes are short (sc-<hash>-<index>, no Foo__Bar- prefix), which matters when your server output is the final product — static HTML, reports, e-mails — and every byte of class repeats per node. Debugging stays readable in dev: your local Babel adds display names to src/, not to the published artifact.

The styled-components SSR path

Collect styles with ServerStyleSheet — the pattern below is extracted from a production SSG consumer:

import { renderToStaticMarkup } from 'react-dom/server';
import { ServerStyleSheet } from 'styled-components';
import { ApollionProvider } from '@apollion-dsi/core/themes';
 
function renderPage(tree: React.ReactElement): string {
  const sheet = new ServerStyleSheet();
  try {
    const body = renderToStaticMarkup(sheet.collectStyles(tree));
    const styles = sheet.getStyleTags(); // <style> tags with the collected CSS
    return `<!DOCTYPE html>
<html>
<head>${styles}</head>
<body>${body}</body>
</html>`;
  } finally {
    sheet.seal();
  }
}
 
renderPage(
  <ApollionProvider>
    <App />
  </ApollionProvider>,
);

For hydrating apps (Next.js and friends), use renderToString instead of renderToStaticMarkup and inject sheet.getStyleElement() into your document — the deterministic ids guarantee the client picks up the exact server tree. The docs website you are reading does exactly this through Next.js.

Per-request theming

Theme resolution is pure and synchronous — createTheme runs anywhere Node runs:

import { ApollionModeScript, readModeFromCookie } from '@apollion-dsi/core/themes/mode';
import { ApollionProvider } from '@apollion-dsi/core/themes';
 
// e.g. brand from the request's tenant, mode preference from the cookie the
// provider persists (`persist="cookie"`)
const preference = readModeFromCookie(request.headers.cookie) ?? 'system';
 
renderPage(
  <>
    <ApollionModeScript persist="cookie" defaultMode="system" />
    <ApollionProvider
      colors={tenantSeeds}
      defaultMode={preference}
      initialMode={preference === 'dark' ? 'dark' : 'light'}
      persist="cookie"
    >
      <App />
    </ApollionProvider>
  </>,
);

Two facts keep server and client honest with each other:

  • initialMode is the hydration contract. Server and first client render use it; the persisted/system preference is applied in a layout effect — after hydration, before paint — so React never sees a mismatch. The pre-paint script (<ApollionModeScript /> / getModeScript()) writes the same <html> attributes before the first paint.
  • The flip is CSS, not a re-render. Every color is a light-dark() pair and the mode is decided by color-scheme on <html> — class hashes are identical in both modes and the server output is byte-deterministic. Details: Dark Mode Engine.

No React on the page? Use the tokens CSS surface

For SSG output, reports or any consumer that doesn't re-bundle React, the @apollion-dsi/tokens CLI emits the whole resolved theme as CSS custom properties:

apollion-tokens build --config apollion.config.mjs --out dist/
# → dist/css/<variant>.css  (--apollion-color-primary-base, --apollion-tx-primary,
#   --apollion-color-primary-on-light, …)

Link the variant stylesheet and consume the vars from any markup. This is the right layer when the runtime theme object would be dead weight — the values are identical by construction (the tokens build mirrors the core engine, guarded by parity tests).

Deterministic builds as a cache ally

apollion-tokens build is byte-identical across reruns for the same config (no timestamps, no run ids), and each output is sha256-fingerprinted in a build manifest. Practical consequence: your static pipeline can cache on configHash and skip whole rebuilds, and CDN caches of the emitted CSS bust exactly when the theme actually changes — never spuriously.

Checklist

ScenarioReach for
Next.js / hydrating SSRServerStyleSheet + renderToString + provider
Static HTML, reports, e-mails (SSG)renderToStaticMarkup + short sc-* classes
Dark mode in static outputcss/<brand>.<surface>.<dimension>.modes.css (output.cssModes) + getModeScript()
Markup without Reactdist/css/ vars from apollion-tokens build
Cache/CIbuild manifest configHash as the cache key

See also