Article

Livery Deep Dive: From CSS Fundamentals to Multi-Tenant Theming

by Claudia Nadalin on Fri Dec 26 2025

I recently published an open-source library called Livery. It's a Type-safe theming solution for TypeScript applications that can take you from simple dark mode to multi-tenant SaaS.

I'm a big believer in understanding the problem before the solution. So instead of jumping straight into API docs, I wanted to start from the very beginning — what CSS does, how browsers render pages, why theming gets tricky at scale. Whether you're a seasoned developer or just getting started, you shouldn't need prior knowledge to follow along.

This guide will take you from CSS basics all the way to understanding how Livery works under the hood. By the end, you'll understand not just how to use Livery, but why it's designed the way it is.

Table of Contents

  1. Part 1: CSS Fundamentals
  2. Part 2: CSS Variables (Custom Properties)
  3. Part 3: How Browsers Render Pages
  4. Part 4: React Rendering Strategies
  5. Part 5: The Theming Problem
  6. Part 6: The Usage Matrix
  7. Part 7: How Livery Works Under the Hood
  8. Part 8: Livery's React Integration
  9. Part 9: Implementation Deep Dive

Part 1: CSS Fundamentals

What CSS Actually Does

CSS (Cascading Style Sheets) tells the browser how to display HTML elements. When a browser loads a page, it:

  1. Parses the HTML into a DOM (Document Object Model) tree
  2. Parses the CSS into a CSSOM (CSS Object Model)
  3. Combines them into a "render tree"
  4. Calculates the layout (where things go)
  5. Paints pixels to the screen
HTML + CSS → DOM + CSSOM → Render Tree → Layout → Paint → Display

The Cascade

The "C" in CSS stands for "Cascading". This means styles can come from multiple sources and the browser has rules for which one wins:

css
/* From a stylesheet */ button { color: blue; } /* More specific selector wins */ .primary-button { color: green; } /* Even more specific */ #submit-btn { color: red; } /* Inline styles beat almost everything */ <button style="color: purple">

Specificity hierarchy (highest to lowest):

  1. !important (nuclear option, avoid)
  2. Inline styles (style="...")
  3. IDs (#id)
  4. Classes, attributes, pseudo-classes (.class, [attr], :hover)
  5. Elements, pseudo-elements (div, ::before)

Where CSS Can Live

CSS can be loaded in several ways:

html
<!-- 1. External stylesheet (most common) --> <link rel="stylesheet" href="/styles.css"> <!-- 2. Style tag in the document --> <style> .button { color: blue; } </style> <!-- 3. Inline on elements --> <button style="color: blue">Click</button>

Why this matters for Livery: We need to inject theme styles dynamically. We can either:

  • Create a <style> tag and insert it into the document
  • Apply inline styles to a wrapper element
  • Let you handle it yourself (for advanced cases)

Part 2: CSS Variables (Custom Properties)

The Game Changer

CSS Variables (officially "Custom Properties") are the foundation of modern theming. They let you define values once and reuse them everywhere:

css
/* Define variables on :root (the <html> element) */ :root { --brand-color: #3b82f6; --spacing-md: 16px; --font-size-base: 16px; } /* Use them anywhere */ .button { background: var(--brand-color); padding: var(--spacing-md); font-size: var(--font-size-base); }

Why CSS Variables Are Special

Unlike preprocessor variables (Sass, Less), CSS variables are:

  1. Live in the browser - They can change at runtime
  2. Inherited - Child elements inherit from parents
  3. Scoped - Can be different in different parts of the page
css
/* Global default */ :root { --text-color: black; } /* Override for dark sections */ .dark-theme { --text-color: white; } /* All children of .dark-theme see white */ .dark-theme p { color: var(--text-color); /* white! */ }

Dynamic Updates

This is the magic. You can change CSS variables with JavaScript and the page updates instantly:

javascript
// Change the brand color for the entire page document.documentElement.style.setProperty('--brand-color', '#ff0000'); // Or scope it to a specific element document.querySelector('.sidebar').style.setProperty('--brand-color', '#00ff00');

Why this matters for Livery: When a tenant's theme loads, we just update CSS variables. Every component using those variables updates automatically - no re-render needed for the style change itself.

Fallback Values

CSS variables can have fallbacks:

css
.button { /* If --brand-color isn't defined, use blue */ background: var(--brand-color, #3b82f6); /* Can even chain them */ color: var(--text-color, var(--fallback-color, black)); }

Part 3: How Browsers Render Pages

Understanding the render pipeline helps you understand why certain approaches cause "flashes" of wrong content.

The Critical Rendering Path

When you navigate to a page:

1. Browser requests HTML 2. Browser starts parsing HTML 3. Browser encounters <link> or <style> 4. CSS is "render-blocking" - browser waits for it 5. Once CSS is ready, browser can paint 6. Browser encounters <script> 7. JavaScript executes (can modify DOM/styles) 8. More painting if things changed

Render-Blocking Resources

CSS is render-blocking by default. The browser won't show anything until it has parsed all CSS in the <head>. This prevents "Flash of Unstyled Content" (FOUC).

html
<head> <!-- Browser waits for this before showing anything --> <link rel="stylesheet" href="/styles.css"> </head>

JavaScript can also block rendering if it's in the <head> without async or defer:

html
<head> <!-- This blocks everything! --> <script src="/app.js"></script> <!-- These don't block rendering --> <script src="/app.js" defer></script> <script src="/app.js" async></script> </head>

Flash of Unstyled/Wrong Content

The dreaded "flash" happens when:

  1. FOUC (Flash of Unstyled Content): Page shows before CSS loads
  2. FOWC (Flash of Wrong Content): Page shows with wrong styles, then corrects

For theming, FOWC is the big problem:

User visits app → Default theme shows → Tenant theme loads → Page "flashes" to correct theme

Why this matters for Livery: We need to get the right theme CSS into the page before the browser paints. This is why SSR and init scripts are important for avoiding flashes.


Part 4: React Rendering Strategies

Now let's talk about how React apps get HTML to the browser. This is crucial for understanding when themes load.

Client-Side Rendering (CSR)

The traditional React approach:

html
<!-- What the server sends --> <!DOCTYPE html> <html> <head> <link rel="stylesheet" href="/styles.css"> </head> <body> <div id="root"></div> <!-- Empty! --> <script src="/bundle.js"></script> </body> </html>

The timeline:

1. Browser loads HTML (with empty <div id="root">) 2. Browser loads JavaScript bundle 3. React runs and renders components 4. Components fetch data (like themes) 5. React updates the DOM 6. User finally sees content

Pros: Simple, cheap hosting (static files) Cons: Slow initial load, SEO challenges, flash of loading states

For theming: The theme can't load until JavaScript runs. User sees a loading spinner or default theme first.

Server-Side Rendering (SSR)

The server renders React to HTML before sending it:

html
<!-- What the server sends --> <!DOCTYPE html> <html> <head> <link rel="stylesheet" href="/styles.css"> <style>:root { --brand-color: #3b82f6; }</style> <!-- Theme CSS! --> </head> <body> <div id="root"> <!-- Actual content, not empty! --> <header>Welcome to Acme Corp</header> <main>...</main> </div> <script src="/bundle.js"></script> </body> </html>

The timeline:

1. Server receives request 2. Server determines tenant (from URL, cookie, header) 3. Server fetches theme data 4. Server renders React to HTML string 5. Server injects theme CSS into <head> 6. Browser receives complete HTML with styles 7. User sees styled content immediately 8. JavaScript loads and "hydrates" (makes interactive)

Pros: Fast first paint, SEO-friendly, no loading flash Cons: Slower server response, more complex infrastructure

For theming: Theme CSS is in the HTML from the start. No flash!

Static Site Generation (SSG)

Pages are rendered at build time, not request time:

bash
# At build time $ npm run build # Creates /out/acme.html, /out/globex.html, etc.

Pros: Fastest possible response (just serving files) Cons: Can't personalize per-request, rebuild needed for changes

For theming: Works great if you know all tenants at build time and themes rarely change.

Incremental Static Regeneration (ISR)

A hybrid: static pages that rebuild in the background:

javascript
// Next.js example export async function getStaticProps() { const theme = await fetchTheme('acme'); return { props: { theme }, revalidate: 60, // Rebuild every 60 seconds }; }

For theming: Good balance - fast serving with periodic theme updates.

The Hydration Process

"Hydration" is when client-side React attaches to server-rendered HTML:

Server HTML: <button>Click me</button> (static, can't click) Hydration: React attaches event listeners Interactive: <button onClick={...}>Click me</button> (works!)

Critical rule: The server HTML and client's first render must match exactly, or React will throw errors or cause visual glitches.

For theming: If the server renders with theme A but the client tries to render with theme B, you get hydration mismatches. This is why initialTheme prop exists - to ensure client starts with the same theme the server used.


Part 5: The Theming Problem

Now you understand the pieces. Let's see why theming is hard.

Two Dimensions of Theming

Theming decisions fall along two independent axes:

Axis 1: Where do themes come from?

Static ThemesDynamic Themes
Known at build timeFetched at runtime
Light/dark modeMulti-tenant SaaS
Bundled in the appStored in database/API
Can't change without deployCan change without deploy

Axis 2: Where does rendering happen?

Client-Side Rendering (CSR)Server-Side Rendering (SSR)
Browser renders everythingServer renders HTML
JavaScript required for first paintContent visible before JS
Simple hosting (static files)Requires server/serverless
Theme applied after JS loadsTheme in initial HTML

The Flash Problem

"Flash" occurs when the user sees wrong content before the correct content appears. For theming, this means:

User visits → Wrong theme visible → Correct theme loads → Page "flashes" to correct theme

The key question: How do we get the correct theme CSS applied before the browser paints?

The answer depends on which quadrant of the usage matrix you're in.


Part 6: The Usage Matrix

These two axes create four distinct quadrants. Livery provides solutions for all four, each with different flash-prevention strategies.

│ Client-Side Rendering │ Server-Side Rendering ────────────────────┼───────────────────────┼─────────────────────── Static Themes │ ✅ No Flash │ ✅ No Flash (bundled) │ Init script │ CSS in HTML ────────────────────┼───────────────────────┼─────────────────────── Dynamic Themes │ ⚠️ Has Flash │ ✅ No Flash (fetched) │ Loading state │ SSR + initialTheme ────────────────────┴───────────────────────┴───────────────────────

Quadrant 1: Static + CSR (Light/Dark Mode)

Use case: Light/dark mode toggle where user preference is stored in localStorage.

Flash prevention: The getThemeInitScript function generates a synchronous script that runs before React, reading localStorage and setting data-theme immediately.

typescript
// theme.ts import { toCssStringAll } from '@livery/core'; import { createStaticThemeProvider, getThemeInitScript } from '@livery/react'; // All CSS is generated at build time export const themesCss = toCssStringAll({ schema, themes: { light: lightTheme, dark: darkTheme }, defaultTheme: 'light', }); // Init script reads localStorage before paint export const themeInitScript = getThemeInitScript({ themes: ['light', 'dark'], defaultTheme: 'light', }); export const { StaticThemeProvider, useTheme } = createStaticThemeProvider({ themes: ['light', 'dark'] as const, defaultTheme: 'light', });
tsx
// layout.tsx <html> <head> {/* All theme CSS - both light and dark */} <style dangerouslySetInnerHTML={{ __html: themesCss }} /> {/* Runs synchronously before paint */} <script dangerouslySetInnerHTML={{ __html: themeInitScript }} /> </head> <body> <StaticThemeProvider>{children}</StaticThemeProvider> </body> </html>

Timeline (no flash):

1. Browser loads HTML 2. Browser parses <style> with all theme CSS 3. Browser executes init script (reads localStorage, sets data-theme) 4. First paint - correct theme is already applied! 5. React hydrates 6. StaticThemeProvider syncs with DOM state

Why no flash: The init script runs synchronously before the browser paints. By the time anything is visible, the correct data-theme attribute is already set, and the CSS already includes rules for both themes scoped by [data-theme="light"] and [data-theme="dark"].

Quadrant 2: Static + SSR

Use case: Static site generation where theme is determined at build/request time.

Flash prevention: Theme CSS is included in the server-rendered HTML.

typescript
// At build time or request time const theme = determineTheme(request); // 'light' or 'dark' const css = toCssString({ schema, theme: themes[theme] });
tsx
// Server render <html data-theme={theme}> <head> <style dangerouslySetInnerHTML={{ __html: css }} /> </head> <body>{children}</body> </html>

Timeline (no flash):

1. Server determines theme 2. Server renders HTML with CSS and data-theme attribute 3. Browser receives complete HTML 4. First paint - correct theme is already in the HTML 5. React hydrates

Why no flash: CSS is render-blocking, so the browser won't paint until CSS is parsed. Since the theme CSS is in the initial HTML, the first paint is already correct.

Quadrant 3: Dynamic + CSR

Use case: Multi-tenant app with client-side only rendering (no server).

Reality: This quadrant will have a flash or loading state. There's no way around it - if you need to fetch theme data and you're doing CSR, JavaScript must run before the theme is known.

tsx
<DynamicThemeProvider themeId={tenantId} resolver={resolver} fallback={<LoadingSpinner />} > <App /> </DynamicThemeProvider>

Timeline (has flash/loading):

1. Browser loads HTML (empty or with default styles) 2. Browser loads JavaScript 3. React renders, DynamicThemeProvider mounts 4. Theme fetch starts - user sees fallback or default theme 5. Theme arrives 6. Correct theme applied - visible change!

Mitigation strategies:

  • Use a tasteful loading state (skeleton, spinner)
  • Cache themes aggressively so repeat visits are fast
  • Use SSR if flash is unacceptable (see Quadrant 4)

Quadrant 4: Dynamic + SSR (Multi-Tenant with SSR)

Use case: Multi-tenant SaaS where themes come from an API but you want no flash.

Flash prevention: Server fetches theme, renders HTML with CSS, and passes initialTheme to client.

typescript
// Server (e.g., Next.js Server Component) import { getLiveryServerProps } from '@livery/react/server'; const themeId = getThemeIdFromRequest(request); const liveryProps = await getLiveryServerProps({ schema, themeId, resolver, });
tsx
// layout.tsx (Server Component) <html> <head> <style dangerouslySetInnerHTML={{ __html: liveryProps.css }} /> </head> <body> <DynamicThemeProvider themeId={themeId} resolver={resolver} initialTheme={liveryProps.initialTheme} > {children} </DynamicThemeProvider> </body> </html>

Timeline (no flash):

1. Server receives request 2. Server extracts themeId from URL/subdomain/header 3. Server fetches theme via resolver 4. Server renders HTML with theme CSS 5. Browser receives complete HTML with CSS 6. First paint - correct theme already applied! 7. JavaScript loads 8. React hydrates with initialTheme (matches server render)

Why no flash: The theme is resolved server-side and injected into the HTML. The initialTheme prop ensures hydration matches the server render exactly.

Next.js Integration (@livery/next)

For Next.js apps, @livery/next provides middleware to extract theme IDs from requests:

typescript
// middleware.ts import { createLiveryMiddleware } from '@livery/next/middleware'; export const middleware = createLiveryMiddleware({ strategy: 'subdomain', subdomain: { baseDomain: 'yourapp.com', ignore: ['www', 'app'], }, fallback: '/select-workspace', });
typescript
// app/layout.tsx import { getThemeFromHeaders, getLiveryData } from '@livery/next'; export default async function Layout({ children }) { const headersList = await headers(); const themeId = getThemeFromHeaders({ headers: headersList }) ?? 'default'; const { theme, css } = await getLiveryData({ themeId, schema, resolver }); return ( <html> <head> <style dangerouslySetInnerHTML={{ __html: css }} /> </head> <body> <DynamicThemeProvider themeId={themeId} initialTheme={theme} resolver={resolver}> {children} </DynamicThemeProvider> </body> </html> ); }

Choosing Your Quadrant

QuestionIf Yes →If No →
Do you know all themes at build time?StaticDynamic
Do you need per-request customization?DynamicStatic
Can you run server code?SSR availableCSR only
Is flash acceptable?CSR is fineNeed SSR or Static+InitScript

Recommended combinations:

Use CaseQuadrantFlash?
Light/dark modeStatic + CSRNo (init script)
Brand theming (known tenants)Static + SSRNo
Multi-tenant SaaSDynamic + SSRNo
Prototype/MVPDynamic + CSRYes (acceptable)

Part 7: How Livery Works Under the Hood

Now let's dive into the actual code.

The Schema System (@livery/core)

Everything starts with a schema. Think of it as a "contract" for what your theme contains:

typescript
// schema.ts import { createSchema, t } from '@livery/core'; export const schema = createSchema({ definition: { brand: { primary: t.color().default('#3b82f6'), secondary: t.color().default('#64748b'), }, spacing: { md: t.dimension().default('16px'), }, }, });

What t.color() and t.dimension() do:

These are "token builders" that:

  1. Define the type of value (for validation)
  2. Store metadata (like default values)
  3. Enable TypeScript to infer the shape
typescript
// Simplified version of what's happening class ColorTokenBuilder { private _default?: string; default(value: string) { this._default = value; return this; } } function color() { return new ColorTokenBuilder(); }

What createSchema produces:

typescript
const schema = { definition: { /* your definition */ }, // Internal methods for working with the schema // Used by resolver, validation, and CSS generation }

The Resolver

The resolver is responsible for turning themeId → theme:

typescript
import { createResolver } from '@livery/core'; const resolver = createResolver({ schema, fetcher: async ({ themeId }) => { // Your logic to get theme data const response = await fetch(`/api/themes/${themeId}`); return response.json(); }, cache: { ttl: 60000, // Cache for 1 minute staleWhileRevalidate: true, }, });

What happens when you call resolver.resolve({ themeId: 'acme' }):

typescript
async function resolve({ themeId }): Promise<Theme> { // 1. Check cache const cached = this.cache.get(themeId); if (cached && !cached.expired) { return cached.theme; } // 2. Fetch partial theme (tenant overrides) const partialTheme = await this.fetcher({ themeId }); // e.g., { brand: { primary: '#ff0000' } } // 3. Merge with defaults from schema const defaults = buildDefaults(this.schema); // e.g., { brand: { primary: '#3b82f6', secondary: '#64748b' }, spacing: { md: '16px' } } const fullTheme = deepMerge(defaults, partialTheme); // e.g., { brand: { primary: '#ff0000', secondary: '#64748b' }, spacing: { md: '16px' } } // 4. Validate and coerce values const result = coerce({ schema: this.schema, data: fullTheme }); // 5. Cache the result this.cache.set(themeId, result.data); // 6. Return complete theme return result.data; }

Key insight: Tenants don't need to specify every value. They override what they want; everything else falls back to schema defaults.

Converting Theme to CSS Variables

Livery transforms the nested theme object into flat CSS variables:

typescript
import { toCssVariables } from '@livery/core'; const theme = { brand: { primary: '#ff0000', secondary: '#64748b', }, spacing: { md: '16px', }, }; const css = toCssVariables({ schema, theme }); // Result: // { // '--brand-primary': '#ff0000', // '--brand-secondary': '#64748b', // '--spacing-md': '16px', // }

The algorithm (simplified):

typescript
function toCssVariables(obj, prefix = '') { const result = {}; for (const [key, value] of Object.entries(obj)) { const cssKey = prefix ? `${prefix}-${key}` : key; if (typeof value === 'object') { // Recurse into nested objects Object.assign(result, toCssVariables(value, cssKey)); } else { // Convert to CSS variable name result[`--${cssKey}`] = value; } } return result; }

Flattening nested paths:

{ brand: { primary: '#ff0000' } } --brand-primary: #ff0000

Part 8: Livery's React Integration

The Factory Pattern

Livery uses a factory pattern to create typed components:

typescript
// theme.ts import { createDynamicThemeProvider } from '@livery/react'; import { schema } from './schema'; export const { DynamicThemeProvider, useTheme, useThemeValue, useThemeReady, } = createDynamicThemeProvider({ schema });

Why a factory?

TypeScript needs to know the shape of your theme to provide autocomplete and type checking:

typescript
// This gives you autocomplete! const primary = useThemeValue('brand.primary'); // ↑ TypeScript knows valid paths const invalid = useThemeValue('brand.invalid'); // ↑ TypeScript error!

The factory captures your schema type and bakes it into the returned hooks.

Inside DynamicThemeProvider

Let's trace what happens when you render:

tsx
<DynamicThemeProvider themeId="acme" resolver={resolver}> <App /> </DynamicThemeProvider>

Step 1: Initialize State (using useSyncExternalStore)

typescript
// Livery uses a store pattern for optimal React 18 compatibility const store = createThemeStore<T>(initialTheme); // State follows a discriminated union pattern: type ThemeState<T> = | { status: 'idle' } | { status: 'loading' } | { status: 'ready'; theme: InferTheme<T> } | { status: 'error'; error: Error };

Step 2: Fetch Theme on Mount/Change

typescript
useEffect(() => { let cancelled = false; async function loadTheme() { store.setLoading(); try { const theme = await resolver.resolve({ themeId }); if (!cancelled) { const cssVars = toCssVariables({ schema, theme }); store.setReady(theme, cssVars); } } catch (error) { if (!cancelled) { store.setError(error); onError?.(error); } } } loadTheme(); return () => { cancelled = true; }; }, [themeId, resolver]);

Step 3: Inject CSS into Document

typescript
useEffect(() => { if (state.status !== 'ready') return; // Create or get existing style tag let styleEl = document.getElementById('livery-theme'); if (!styleEl) { styleEl = document.createElement('style'); styleEl.id = 'livery-theme'; document.head.appendChild(styleEl); } // Generate CSS const css = `:root {\n${ Object.entries(cssVariables) .map(([key, value]) => ` ${key}: ${value};`) .join('\n') }\n}`; styleEl.textContent = css; }, [cssVariables]);

The result in your document:

html
<head> <!-- Livery injects this --> <style id="livery-theme"> :root { --brand-primary: #ff0000; --brand-secondary: #64748b; --spacing-md: 16px; } </style> </head>

Step 4: Provide Context

typescript
const contextValue = { state, themeId, setThemeId, cssVariables, refresh, // Convenience accessors theme: state.status === 'ready' ? state.theme : null, isIdle: state.status === 'idle', isLoading: state.status === 'loading', isReady: state.status === 'ready', isError: state.status === 'error', error: state.status === 'error' ? state.error : null, }; return ( <ThemeContext.Provider value={contextValue}> {state.status === 'loading' && fallback ? fallback : children} </ThemeContext.Provider> );

CSS Injection Strategies

Livery supports three modes:

1. Style Tag (default)

tsx
<DynamicThemeProvider injection="style-tag">
html
<head> <style id="livery-theme"> :root { --brand-primary: #ff0000; } </style> </head> <body> <div id="root"> <!-- Your app --> </div> </body>

Pros: CSS variables are globally available, works with any CSS approach Cons: Modifies document head (might conflict with other tools)

2. Inline Styles

tsx
<DynamicThemeProvider injection="inline">
html
<body> <div id="root"> <div style="--brand-primary: #ff0000; --spacing-md: 16px;"> <!-- Your app (CSS vars scoped to this tree) --> </div> </div> </body>

Pros: Scoped to your app, no global side effects Cons: CSS variables only available inside the wrapper div

3. None (Manual)

tsx
<DynamicThemeProvider injection="none"> {/* You handle CSS injection yourself */} </DynamicThemeProvider>
typescript
function MyComponent() { const { cssVariables } = useTheme(); // Do whatever you want with cssVariables }

Pros: Full control Cons: You have to do the work

The useThemeValue Hook

This hook provides type-safe access to individual values:

typescript
function useThemeValue<P extends ThemePath<Schema>>(path: P): PathValue<Schema, P> { const context = useContext(ThemeContext); if (!context) { throw new Error('useThemeValue must be used within DynamicThemeProvider'); } // Navigate the nested object using the path // 'brand.primary' → theme.brand.primary return getValueAtPath(context.theme, path); }

How path types work (advanced TypeScript):

typescript
// Given this schema definition: { brand: { primary: ColorTokenBuilder, secondary: ColorTokenBuilder, }, spacing: { md: DimensionTokenBuilder, }, } // ThemePath generates union of valid paths: type ThemePath = 'brand.primary' | 'brand.secondary' | 'spacing.md'; // PathValue maps path to return type: type Result = PathValue<Schema, 'brand.primary'>; // string

This is why you get autocomplete when typing paths!


Part 9: Implementation Deep Dive

This section covers advanced implementation details for SSR, hydration, and the complete server/client flow.

The CSR vs SSR Timeline

With client-side rendering:

1. Browser loads empty HTML 2. Browser loads JavaScript 3. React starts rendering 4. DynamicThemeProvider mounts 5. Theme fetch starts 6. User sees loading spinner (or default theme) 7. Theme arrives 8. User sees correct theme

The gap between steps 6 and 8 is the "flash".

SSR Solution

With server-side rendering:

[SERVER] 1. Request arrives with tenant info 2. Server fetches theme 3. Server renders React with theme 4. Server injects CSS variables into HTML 5. Server sends complete HTML [BROWSER] 6. Browser receives HTML with CSS already present 7. User sees correct theme immediately 8. JavaScript loads 9. React hydrates (attaches to existing HTML) 10. No flash!

The Server Utilities

getLiveryServerProps

This function does on the server what DynamicThemeProvider does on the client:

typescript
// entry-server.tsx import { getLiveryServerProps } from '@livery/react/server'; const liveryProps = await getLiveryServerProps({ schema, themeId: 'acme', resolver, }); // Returns: // { // themeId: 'acme', // initialTheme: { brand: { primary: '#ff0000', ... }, ... }, // css: ':root { --brand-primary: #ff0000; ... }', // }

LiveryScript

This component renders a <style> tag with the CSS:

tsx
import { LiveryScript } from '@livery/react/server'; function Document({ liveryProps }) { return ( <html> <head> <LiveryScript css={liveryProps.css} /> {/* Renders: <style id="livery-critical">:root { ... }</style> */} </head> <body>...</body> </html> ); }

Hydration: Making It Interactive

After the server sends HTML, the client needs to "hydrate" - attach React's event handlers to the existing DOM.

The critical requirement: Client's first render must match server's render exactly.

If the server rendered with theme A, but the client tries to render with theme B, React will either:

  1. Throw a hydration mismatch error
  2. Cause visual glitches as it "fixes" the DOM

Solution: initialTheme prop

tsx
// Server embeds the theme data in the HTML <script> window.__LIVERY_DATA__ = { themeId: 'acme', initialTheme: { brand: { primary: '#ff0000' }, ... } }; </script> // Client reads it and passes to provider <DynamicThemeProvider themeId={window.__LIVERY_DATA__.themeId} resolver={resolver} initialTheme={window.__LIVERY_DATA__.initialTheme} >

When initialTheme is provided:

  1. Provider skips the initial fetch
  2. Uses the provided theme immediately
  3. Renders match server exactly
  4. No loading state, no flash, no hydration mismatch

The Complete SSR Flow

[SERVER] 1. Request: GET /?tenant=acme 2. Server code: const liveryProps = await getLiveryServerProps({ schema, themeId, resolver }); 3. Render app: const html = renderToString( <DynamicThemeProvider initialTheme={liveryProps.initialTheme} ...> <App /> </DynamicThemeProvider> ); 4. Inject into template: <html> <head> <style id="livery-critical">${liveryProps.css}</style> </head> <body> <div id="root">${html}</div> <script>window.__LIVERY_DATA__ = ${JSON.stringify(liveryProps)};</script> <script src="/bundle.js"></script> </body> </html> 5. Send to browser [BROWSER] 6. Browser receives complete HTML 7. CSS is already in <head>, page renders correctly 8. User sees themed content immediately 9. JavaScript loads and executes: hydrateRoot( document.getElementById('root'), <DynamicThemeProvider initialTheme={window.__LIVERY_DATA__.initialTheme} ... > <App /> </DynamicThemeProvider> ); 10. React attaches to existing DOM (no visual change) 11. App is now interactive

Summary

The Usage Matrix (Quick Reference)

│ Client-Side Rendering │ Server-Side Rendering ────────────────────┼───────────────────────┼─────────────────────── Static Themes │ ✅ No Flash │ ✅ No Flash (bundled) │ Init script │ CSS in HTML ────────────────────┼───────────────────────┼─────────────────────── Dynamic Themes │ ⚠️ Has Flash │ ✅ No Flash (fetched) │ Loading state │ SSR + initialTheme ────────────────────┴───────────────────────┴───────────────────────

Key Concepts Recap

  1. CSS Variables are live in the browser and can be changed at runtime
  2. Render-blocking CSS in <head> prevents flashes
  3. SSR gets content into HTML before JavaScript runs
  4. Hydration attaches React to server-rendered HTML
  5. The flash happens when visible content changes after initial paint
  6. Static themes + CSR avoid flash via the init script
  7. Dynamic themes require SSR to avoid flash

What Livery Does

  1. Schema defines theme structure with type safety
  2. Resolver fetches and caches tenant themes
  3. Provider manages state and injects CSS variables
  4. Hooks provide type-safe access to values
  5. Server utilities enable SSR without flash
  6. Init script prevents flash for static themes in CSR
  7. Next.js middleware handles tenant detection

The Architecture

Schema (structure) Resolver (themeId → theme) ← only for dynamic themes Provider (manages state, injects CSS) Hooks (access values) Your Components (use CSS variables or hook values)

Packages Overview

@livery/core ├── Schema creation (createSchema, t) ├── Validation (validate, coerce) ├── CSS generation (toCssVariables, toCssString, toCssStringAll) └── Resolver (createResolver) @livery/react ├── Dynamic themes (createDynamicThemeProvider) ├── Static themes (createStaticThemeProvider, getThemeInitScript) ├── Hooks (useTheme, useThemeValue, useThemeReady) └── Server utilities (getLiveryServerProps, LiveryScript) @livery/next ├── Middleware (createLiveryMiddleware) ├── Theme extraction (getThemeFromHeaders) └── Server resolution (getLiveryData, getCacheHeaders)

When to Use Each Quadrant

Use CaseQuadrantPackageFlash?
Light/dark modeStatic + CSR@livery/reactNo (init script)
Known brand themesStatic + SSR@livery/reactNo
Multi-tenant SaaSDynamic + SSR@livery/nextNo
Prototype/MVPDynamic + CSR@livery/reactYes

You now have the foundation to understand not just Livery, but any CSS-in-JS library, theming system, or SSR framework. The concepts of CSS variables, render timing, and hydration apply everywhere in modern frontend development.

Subscribe to the Newsletter

Developer insights, project updates, and the occasional Seinfeld reference. No spam, no shrinkage.