The pain this guide solves
You add dark mode to Next.js and get a flash of the wrong theme on every hard load. CSS-in-JS libraries hydrate late, localStorage reads happen client-side, and the first paint is always wrong. Theming and SSR feel fundamentally incompatible.
salt-theme-gen with Next.js
What you will build
- CSS variables generated at build time and injected into the server-rendered HTML
- Zero flash of unstyled content — the correct theme is in the HTML before JavaScript runs
- Dark mode that respects
prefers-color-schemewithout a client-side read - A
useTheme()hook for typed token access in client components
Time required: 15 minutes.
Install
npm install salt-theme-gen
Step 1 — Generate theme and CSS variables
Create lib/theme.ts at the project root:
import { generateTheme } from 'salt-theme-gen';
import type { GeneratedThemeMode } from 'salt-theme-gen';
export const theme = generateTheme({
preset: 'ocean',
spacing: 'default',
radius: 'default',
fontSize: 'default',
});
function kebab(str: string): string {
return str.replace(/([A-Z])/g, '-$1').toLowerCase();
}
function modeToVars(mode: GeneratedThemeMode): string {
const lines: string[] = [];
for (const [k, v] of Object.entries(mode.colors))
lines.push(`--color-${kebab(k)}: ${v};`);
for (const [k, v] of Object.entries(mode.surfaceElevation))
lines.push(`--surface-${k}: ${v};`);
for (const [k, v] of Object.entries(mode.spacing))
lines.push(`--space-${k}: ${v}px;`);
for (const [k, v] of Object.entries(mode.radius))
lines.push(`--radius-${k}: ${v}px;`);
for (const [k, v] of Object.entries(mode.fontSizes))
lines.push(`--text-${k}: ${v}px;`);
for (const [intent, states] of Object.entries(mode.states))
for (const [state, val] of Object.entries(states as Record<string, string>))
lines.push(`--state-${intent}-${state}: ${val};`);
return lines.join('\n ');
}
export const lightVars = modeToVars(theme.light);
export const darkVars = modeToVars(theme.dark);
// Inlined into <style> in the server-rendered <head>
export const themeCSS = `
:root {
${lightVars}
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
${darkVars}
}
}
:root[data-theme="dark"] { ${darkVars} }
:root[data-theme="light"] { ${lightVars} }
`.trim();
The media query approach means dark mode works with zero JavaScript — the browser applies it natively before any script runs.
App Router setup
Step 2 — Root layout
In app/layout.tsx, inject the theme style tag in the server-rendered <head>:
import type { Metadata } from 'next';
import { themeCSS } from '@/lib/theme';
import './globals.css';
export const metadata: Metadata = {
title: 'My App',
description: 'Built with salt-theme-gen',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
{/* Theme injected server-side — no FOUC */}
<style dangerouslySetInnerHTML={{ __html: themeCSS }} />
{/* Inline script: apply saved preference before first paint */}
<script dangerouslySetInnerHTML={{ __html: `
(function() {
var saved = localStorage.getItem('theme');
if (saved === 'dark' || saved === 'light') {
document.documentElement.setAttribute('data-theme', saved);
}
})();
` }} />
</head>
<body>{children}</body>
</html>
);
}
The inline script runs synchronously before the browser paints — it reads localStorage and sets data-theme on <html> before any React code runs. Combined with the server-side CSS, the result is zero flash.
suppressHydrationWarning on <html> silences the React hydration warning caused by the inline script modifying data-theme before React takes over.
Step 3 — Global CSS
Create app/globals.css:
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
background-color: var(--color-background);
color: var(--color-text);
font-size: var(--text-md);
font-family: system-ui, -apple-system, sans-serif;
line-height: 1.6;
}
a { color: var(--color-primary); }
a:hover { color: var(--state-primary-hover); }
Step 4 — Dark mode toggle (client component)
Create components/ThemeToggle.tsx:
'use client';
import { useEffect, useState } from 'react';
type Mode = 'light' | 'dark' | 'system';
export function ThemeToggle() {
const [mode, setMode] = useState<Mode>('system');
useEffect(() => {
const saved = localStorage.getItem('theme') as Mode | null;
if (saved) setMode(saved);
}, []);
function toggle() {
const next = mode === 'dark' ? 'light' : 'dark';
setMode(next);
localStorage.setItem('theme', next);
document.documentElement.setAttribute('data-theme', next);
}
return (
<button
onClick={toggle}
aria-label="Toggle dark mode"
style={{
background: 'none',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
padding: '6px 10px',
cursor: 'pointer',
color: 'var(--color-text)',
fontSize: 'var(--text-sm)',
}}
>
{mode === 'dark' ? '☀ Light' : '◐ Dark'}
</button>
);
}
Pages Router setup
If you are on the Pages Router (pages/ directory), inject the theme in pages/_document.tsx:
import { Html, Head, Main, NextScript } from 'next/document';
import { themeCSS } from '@/lib/theme';
export default function Document() {
return (
<Html lang="en">
<Head>
<style dangerouslySetInnerHTML={{ __html: themeCSS }} />
<script dangerouslySetInnerHTML={{ __html: `
(function() {
var saved = localStorage.getItem('theme');
if (saved === 'dark' || saved === 'light') {
document.documentElement.setAttribute('data-theme', saved);
}
})();
` }} />
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
The rest of the pattern (CSS variables, toggle hook) is identical between App Router and Pages Router.
Theme context for typed access
For Server Components, CSS variables are sufficient — reference var(--color-primary) in inline styles or CSS classes.
For Client Components that need typed token objects, create a context:
context/ThemeContext.tsx:
'use client';
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import type { GeneratedTheme, GeneratedThemeMode } from 'salt-theme-gen';
import { theme } from '@/lib/theme';
interface ThemeContextValue {
mode: GeneratedThemeMode;
isDark: boolean;
toggle: () => void;
}
const ThemeContext = createContext<ThemeContextValue>({
mode: theme.light,
isDark: false,
toggle: () => {},
});
export function ThemeProvider({ children }: { children: ReactNode }) {
const [isDark, setIsDark] = useState(false);
useEffect(() => {
const saved = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
setIsDark(saved === 'dark' || (!saved && prefersDark));
}, []);
function toggle() {
const next = !isDark;
setIsDark(next);
const label = next ? 'dark' : 'light';
localStorage.setItem('theme', label);
document.documentElement.setAttribute('data-theme', label);
}
return (
<ThemeContext.Provider value={{ mode: isDark ? theme.dark : theme.light, isDark, toggle }}>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => useContext(ThemeContext);
Wrap your layout body (App Router) or _app.tsx (Pages Router):
// app/layout.tsx — body content
<body>
<ThemeProvider>
{children}
</ThemeProvider>
</body>
Using tokens in components
Server Components — CSS variables
// app/page.tsx — Server Component, no 'use client' needed
export default function HomePage() {
return (
<main style={{ padding: 'var(--space-xxl)' }}>
<h1 style={{ color: 'var(--color-text)', fontSize: 'var(--text-3xl)' }}>
Hello world
</h1>
<p style={{ color: 'var(--color-muted)' }}>
Styled with salt-theme-gen tokens.
</p>
</main>
);
}
Client Components — typed tokens via context
'use client';
import { useTheme } from '@/context/ThemeContext';
export function StatusCard({ status }: { status: 'success' | 'danger' | 'warning' }) {
const { mode } = useTheme();
const bg = mode.colors[status];
const color = mode.colors[`on${status.charAt(0).toUpperCase()}${status.slice(1)}` as keyof typeof mode.colors];
return (
<div style={{
background: bg,
color,
borderRadius: `${mode.radius.md}px`,
padding: `${mode.spacing.sm}px ${mode.spacing.lg}px`,
fontSize: `${mode.fontSizes.sm}px`,
fontWeight: 600,
}}>
{status}
</div>
);
}
CSS Modules
/* components/Button.module.css */
.btn {
background: var(--color-primary);
color: var(--color-on-primary);
border: none;
border-radius: var(--radius-md);
padding: var(--space-sm) var(--space-lg);
font-size: var(--text-md);
font-weight: 600;
cursor: pointer;
}
.btn:hover { background: var(--state-primary-hover); }
.btn:active { background: var(--state-primary-pressed); }
.btn:focus-visible {
outline: 2px solid var(--state-primary-focused);
outline-offset: 2px;
}
.btn:disabled {
background: var(--state-primary-disabled);
cursor: not-allowed;
}
import styles from './Button.module.css';
export function Button(props: React.ButtonHTMLAttributes<HTMLButtonElement>) {
return <button className={styles.btn} {...props} />;
}
Tailwind CSS v4 + Next.js
If you use Tailwind CSS v4, you can map tokens to CSS variables in app/globals.css:
@import 'tailwindcss';
@theme {
--color-primary: var(--color-primary);
--color-secondary: var(--color-secondary);
--color-background: var(--color-background);
--color-surface: var(--color-surface);
--color-text: var(--color-text);
--color-muted: var(--color-muted);
--color-border: var(--color-border);
--color-danger: var(--color-danger);
--color-success: var(--color-success);
}
Then use bg-primary, text-text, border-border in your JSX — all driven by generated tokens.
Why not next-themes?
next-themes is a popular dark mode library. You can use it alongside salt-theme-gen:
import { ThemeProvider } from 'next-themes';
// next-themes manages data-theme on <html>
// salt-theme-gen provides the CSS variables those values switch
<ThemeProvider attribute="data-theme" defaultTheme="system">
{children}
</ThemeProvider>
next-themes handles the localStorage persistence and system preference sync. salt-theme-gen handles the actual token values the theme switches between. They are complementary — next-themes is a UI concern, salt-theme-gen is a design system concern.
File structure summary
lib/
└── theme.ts ← generateTheme() + CSS var export
app/ ← App Router
├── layout.tsx ← injects <style> + inline script
├── globals.css ← token-based base styles
└── page.tsx
components/
├── ThemeToggle.tsx ← 'use client' toggle
└── Button.module.css
context/
└── ThemeContext.tsx ← 'use client' typed token provider
Checklist
lib/theme.tsgenerates CSS vars at build time ✓themeCSSinjected in<head>viadangerouslySetInnerHTML✓- Inline
<script>appliesdata-themebefore first paint ✓ globals.cssusesvar(--color-background)on body ✓suppressHydrationWarningon<html>✓ThemeToggleis a Client Component ('use client') ✓- No FOUC on hard reload or SSR ✓
Next.js 15 + React 19: This guide is compatible with Next.js 15 and React 19. The ‘use client’ boundary on the toggle and context provider is required — localStorage and window are not available in Server Components. The theme CSS itself renders server-side with no client dependency.
Live demo
Open this integration in StackBlitz — fully working, editable in your browser.
Open in StackBlitz →