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 themesimport 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 withThemeProvider
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 requestasync 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 exampleconst 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 objectsconst theme = createTheme('brand', tokens); // Server-side<ClientWrapper theme={theme} />; // Serialization error!
// ✅ Correct - pass raw JSON, process on clientconst rawTokens = await fetch(url).then((r) => r.json());<ClientWrapper tokenData={{ rawTokens }} />; // Client calls createThemeHydration 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.
Related Documentation
- Multi-Brand Implementation - Managing multiple themes
- ThemeProvider
- createTheme