Skip to main content

Dynamic Theme Loading

Loading Themes at Runtime

Overview

In white-labeled applications, theme token files are often not available statically and must be fetched from a remote source (CDN, API, database). This guide demonstrates how to load theme tokens asynchronously at runtime while preserving server-side rendering (SSR) to avoid Flash of Unstyled Content (FOUC).

While the examples use Next.js, the core pattern applies to any SSR framework (Remix, SvelteKit, Nuxt, etc.).

The Challenge

Traditional static imports assume theme tokens are available synchronously:

// ❌ Not possible with runtime-determined themes
import tokens from './brand-a-tokens.json';
const flattenedTokens = flattenTokens(tokens);
const theme = createTheme('brandA', { theme: flattenedTokens });

When the theme source is determined at runtime (e.g., from a database, user preference, or admin configuration), you need a different approach.

Solution Architecture

The recommended pattern works across SSR frameworks by separating concerns:

  • Server (SSR): Fetch raw token JSON asynchronously during server render
  • Server → Client: Pass serializable token data via props or initial state
  • Client: Process tokens with flattenTokens()
  • Client: Generate theme with createTheme() and wrap app with ThemeProvider

This ensures:

  • ✅ Theme tokens are included in initial SSR payload (no FOUC)
  • ✅ Styles are server-rendered
  • ✅ Token processing happens once on the client
  • ✅ Theme source can be determined dynamically

Implementation

Step 1: Server-Side Token Fetch

Fetch raw token JSON during server-side rendering. This example uses Next.js App Router, but the concept applies to any SSR framework's loader/server function.

// Server Component - runs once per request
async function loadTokenData() {
try {
const tokenUrl = await getTokenUrlForCurrentDomain();
const response = await fetch(tokenUrl, {
next: { revalidate: 3600 }, // Cache for 1 hour
});
if (!response.ok) {
throw new Error('Failed to fetch tokens');
}
const rawTokens = await response.json();
return {
rawTokens,
success: true,
};
} catch (error) {
console.error('Failed to load theme tokens:', error);
// Return fallback or empty state
return {
rawTokens: null,
success: false,
};
}
}
export default async function RootLayout({ children }) {
const tokenData = await loadTokenData();
return (
<html lang="en">
<body>
<ClientThemeWrapper tokenData={tokenData}>
{children}
</ClientThemeWrapper>
</body>
</html>
);
}

Step 2: Client Theme Wrapper

Create a client component that processes tokens and provides the theme. This component receives the raw tokens from the server. Even though it's marked as a Client Component, it's pre-rendered during SSR, allowing Emotion to extract and inject styles into the initial HTML.

'use client';
import React, { useMemo } from 'react';
import { createTheme, flattenTokens } from '@uhg-abyss/web/tools/theme';
import { ThemeProvider } from '@uhg-abyss/web/ui/ThemeProvider';
interface ClientThemeWrapperProps {
children: React.ReactNode;
tokenData: {
rawTokens: any;
success: boolean;
};
}
export default function ClientThemeWrapper({
children,
tokenData,
}: ClientThemeWrapperProps) {
// Process tokens once when component mounts
const theme = useMemo(() => {
if (tokenData.success && tokenData.rawTokens) {
// Flatten and transform tokens
const flattenedTokens = flattenTokens(tokenData.rawTokens);
// Create theme with styles
return createTheme('yourBrand', { theme: flattenedTokens });
}
// Fallback to default theme
return createTheme('uhc');
}, [tokenData]);
return <ThemeProvider theme={theme}>{children}</ThemeProvider>;
}

Step 3: Use Themed Components

All Abyss components will now use the dynamically loaded theme:

'use client';
import { Button } from '@uhg-abyss/web/ui/Button';
import { SearchInput } from '@uhg-abyss/web/ui/SearchInput';
export default function ThemeTestPage() {
return (
<main>
{/* Components automatically use loaded theme */}
<Heading>Dynamic Theme Applied</Heading>
<Button>Themed Button</Button>
</main>
);
}

Performance Considerations

Caching Strategy

Cache token fetches at multiple levels to reduce network overhead:

// Next.js example
const response = await fetch(tokenUrl, {
next: { revalidate: 3600 }, // Framework cache: 1 hour
headers: { 'Cache-Control': 'public, max-age=3600' }, // CDN cache
});
// Generic approach (works in any framework)
const response = await fetch(tokenUrl, {
headers: { 'Cache-Control': 'public, max-age=3600' },
});

Preloading

Preload tokens in the document <head> to start fetching early:

<!-- Add to your HTML head (works in any framework) -->
<link
rel="preload"
href="/api/theme-tokens.json"
as="fetch"
crossorigin="anonymous"
/>
export default async function RootLayout({ children }) {
const tokenUrl = await getTokenUrl();
return (
<html>
<head>
<link
rel="preload"
href={tokenUrl}
as="fetch"
crossOrigin="anonymous"
/>
</head>
<body>{children}</body>
</html>
);
}

Troubleshooting

FOUC (Flash of Unstyled Content)

Symptom: Page flashes unstyled before theme applies

Solution: Fetch tokens during SSR, not in a client-side effect. Pass the raw tokens from server to client.

// ❌ Wrong - causes FOUC (client-side fetch)
function ClientWrapper({ children }) {
const [theme, setTheme] = useState(null);
useEffect(() => {
fetch('/api/tokens').then((data) => setTheme(createTheme(data)));
}, []);
return <ThemeProvider theme={theme}>{children}</ThemeProvider>;
}
// ✅ Correct - server-side fetch, then pass to client
// (Shown with Next.js, but use your framework's SSR data loading)
async function RootLayout({ children }) {
const tokenData = await loadTokenData(); // SSR: runs on server
return (
<ClientThemeWrapper tokenData={tokenData}>{children}</ClientThemeWrapper>
);
}

Serialization Errors

Symptom: "Objects are not valid as a React child" or serialization errors

Cause: createTheme() generates Emotion CSS objects that contain functions and non-serializable JavaScript primitives. These cannot be sent from server to client components.

Solution: Pass raw token JSON (serializable) from server to client. Do NOT call createTheme() on the server.

// ❌ Wrong - createTheme generates non-serializable Emotion objects
const theme = createTheme('brand', tokens); // Server-side
<ClientWrapper theme={theme} />; // Serialization error!
// ✅ Correct - pass raw JSON, process on client
const rawTokens = await fetch(url).then((r) => r.json());
<ClientWrapper tokenData={{ rawTokens }} />; // Client calls createTheme

Hydration Mismatches

Symptom: React hydration warnings about mismatched content

Solution: Ensure the same token data is used for both server render and client hydration. Don't conditionally process tokens based on window or client-only checks.

  • Multi-Brand Implementation - Managing multiple themes
  • ThemeProvider
  • createTheme
Table of Contents