Next.js theme system with oklch tokens, dark mode, and white-label primitives
Most Next.js templates ship a theme that looks fine and falls apart the moment you try to rebrand it. Colors hardcoded in components, dark mode bolted on as a class soup, and 'just edit the Tailwind config' instructions that turn into a multi-day audit. BoilerlyKit's theme system pushes everything into CSS custom properties so the rebrand surface is small, the dark mode story is honest, and white-labeling for an agency client is a token swap rather than a fork.
Semantic tokens, not raw colors
Components reference semantic tokens (`bg-primary`, `text-muted-foreground`, `border-destructive`), never raw color values. The token names describe role, not appearance: `--primary` is whatever your brand color is, `--muted-foreground` is whatever text-on-muted-surface looks like in your palette. Renaming `--primary` from a blue to a purple does not require editing any component.
Semantic scales cover the surfaces every product actually uses: primary, secondary, accent, muted, destructive, success, warning, info, plus background, foreground, card, popover, and border. Adding a new scale is a token addition; removing one nobody uses is a token deletion. The list is intentionally short because longer lists fall out of sync with reality.
export function PrimaryButton({ children }: { children: React.ReactNode }) {
return (
<button className="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-4 py-2">
{children}
</button>
);
}Why oklch and what you give up
oklch is a perceptually uniform color space, equal numeric steps in lightness or chroma look like equal steps to the human eye. Hex and HSL do not have that property, which is why hand-tuned palettes often feel inconsistent across hues. Defining tokens in oklch means the lighter and darker variants of a color stay visually coherent without manual tuning.
The trade-off is browser support and tooling familiarity. Modern browsers (since 2023) support oklch natively; older browsers fall back gracefully because Tailwind v4 emits the values inline. Design tools are catching up; if your designers work in Figma with hex, the token file accepts either format, you keep the perceptual-uniformity benefit only on the values you write in oklch.
Dark mode as data, not behavior
Dark mode is a `.dark { }` block that overrides the token values, paired with next-themes for the class toggle on `<html>`. There is no theme provider object to maintain; the theme is data, not state. Class-based dark mode (rather than media-query-based) is the default so users can override their system preference, which is what most product users actually want.
Components do not need dark-mode-specific code because they read tokens that already shift in dark mode. A button is `bg-primary text-primary-foreground` in both themes; the token values change, the className does not. That keeps the dark surface honest, no half-themed components that look fine in light mode and break in dark.
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--primary: oklch(0.65 0.18 250);
--primary-foreground: oklch(0.985 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--primary: oklch(0.72 0.18 250);
}Rebranding for white-label or new product launches
Rebranding the whole site is a single-file exercise: edit the token values in `globals.css`, save, refresh. Buttons, badges, focus rings, link colors, and the entire shadcn/ui surface update because they all read from the same tokens. The agency template ships with a color-customizer flow that exposes this to non-developers; the SaaS templates leave it as a developer task because most teams rebrand once per product, not per client.
Typography, spacing, and shadows follow the same pattern: font families and weights via next/font, spacing on an 8px grid, six shadow tiers. Changing a font family is one import in the layout; changing the spacing scale is a Tailwind v4 theme override in CSS. Nothing about the theme system requires a build step beyond what Next.js already runs.