Skip to Content
Livery is in early development. Star us on GitHub!
PatternsStatic Themes

Static Themes in Next.js

Light/dark mode with zero flash and zero runtime overhead.

Static themes
Dynamic themes
SSR
SSR + Static
Themes bundled, server sets data-theme
SSR + Dynamic
CSR
CSR + Static
CSR + Dynamic

Using Remix or Astro? The pattern is the same — read the theme from a cookie, inject CSS with toCssStringAll(), set data-theme on the server. Framework-specific adapters coming soon.

How It Works

  1. All theme CSS is generated at build time with toCssStringAll()
  2. Server reads the user’s preference from a cookie
  3. Server renders with the correct data-theme attribute
  4. No flash — the right theme is there from the first paint

1. Define Schema and Themes

lib/theme.ts
import { createSchema, t, toCssStringAll, type InferTheme } from '@livery/core'; import { createStaticThemeProvider } from '@livery/react'; export const schema = createSchema({ definition: { colors: { primary: t.color(), primaryHover: t.color(), background: t.color(), foreground: t.color(), muted: t.color(), border: t.color(), }, }, }); type Theme = InferTheme<typeof schema.definition>; const light: Theme = { colors: { primary: '#14B8A6', primaryHover: '#0D9488', background: '#FFFFFF', foreground: '#0F172A', muted: '#64748B', border: '#E2E8F0', }, }; const dark: Theme = { colors: { primary: '#2DD4BF', primaryHover: '#14B8A6', background: '#0F172A', foreground: '#F8FAFC', muted: '#94A3B8', border: '#334155', }, }; // Generate CSS for ALL themes at once export const themesCss = toCssStringAll({ schema, themes: { light, dark }, defaultTheme: 'light', }); // Provider and hook for client components export const { StaticThemeProvider, useTheme } = createStaticThemeProvider({ themes: ['light', 'dark'] as const, defaultTheme: 'light', }); export type ThemeName = 'light' | 'dark';

2. Root Layout

Server reads the cookie and injects the CSS:

app/layout.tsx
import { cookies } from 'next/headers'; import { themesCss, StaticThemeProvider, ThemeName } from '@/lib/theme'; import './globals.css'; export default async function RootLayout({ children }: { children: React.ReactNode }) { const cookieStore = await cookies(); const theme = (cookieStore.get('theme')?.value as ThemeName) ?? 'light'; return ( <html lang="en" data-theme={theme}> <head> <style dangerouslySetInnerHTML={{ __html: themesCss }} /> </head> <body> <StaticThemeProvider initialTheme={theme} persist="cookie"> {children} </StaticThemeProvider> </body> </html> ); }

3. Theme Toggle

components/theme-toggle.tsx
'use client'; import { useTheme } from '@/lib/theme'; export function ThemeToggle() { const { theme, setTheme } = useTheme(); return ( <button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')} className="bg-muted/20 px-3 py-1.5 rounded text-sm" > {theme === 'dark' ? 'Light Mode' : 'Dark Mode'} </button> ); }

4. Use Semantic Classes

app/page.tsx
import { ThemeToggle } from '@/components/theme-toggle'; export default function Home() { return ( <main className="min-h-screen bg-background text-foreground p-8"> <div className="flex justify-between items-center mb-8"> <h1 className="text-3xl font-bold">My App</h1> <ThemeToggle /> </div> <p className="text-muted">Theme switching with SSR support.</p> </main> ); }

With Tailwind CSS v4

Map Livery’s CSS variables to Tailwind:

app/globals.css
@import 'tailwindcss'; @theme { --color-primary: var(--colors-primary); --color-primary-hover: var(--colors-primaryHover); --color-background: var(--colors-background); --color-foreground: var(--colors-foreground); --color-muted: var(--colors-muted); --color-border: var(--colors-border); }

Now use bg-background, text-foreground, text-muted etc.

Why This Works

AspectHow
No flashServer sets data-theme before sending HTML
No runtime CSSAll themes in one stylesheet via toCssStringAll()
Persistencepersist="cookie" syncs with server
Type-safeSchema ensures themes match expected shape
Last updated on