Skip to main content

Feature deep-dive · SaaSForge Starter

Next.js theme system with oklch tokens, dark mode, and white-label primitives

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.

A component that survives a rebrand without changes
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.

Light and dark in one CSS block
: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.

Frequently asked

Do I have to use oklch, or can I stick with hex?
You can use any color value the browser understands: hex, HSL, oklch, even named colors. The token names do not care which format the value is in. You lose the perceptual-uniformity benefit on the tokens you write in hex, but everything else (semantic scales, dark mode, rebrand surface) keeps working unchanged.
How does this work with shadcn/ui components?
shadcn/ui components ship with className strings that already reference the semantic tokens (`bg-primary`, `text-muted-foreground`). The BoilerlyKit templates copy shadcn/ui into the repo at clone time, so the components are yours to edit. Updating shadcn/ui later is a manual merge rather than a dependency bump, which is the deliberate trade-off the shadcn approach makes.
Can I theme on a per-tenant basis for a multi-tenant SaaS?
Yes, but it is not the default. SaaSForge Core supports a per-workspace theme override by emitting workspace-specific token values in a server-rendered style tag. Most B2B products do not need this until enterprise customers ask for it; the boilerplate's primitives let you add it without restructuring the rest of the theme system.
What about typography and spacing tokens?
Typography is handled by next/font with three families (sans, serif, mono) loaded at build time and exposed as CSS variables. Spacing follows Tailwind v4's default 8px grid with overrides as needed. Both layers sit alongside the color tokens in the same theme model, so a 'rebrand' covers typeface, spacing, color, and motion when you want it to.
Ships in SaaSForge Starter

See SaaSForge Starter. Skip the deliberation.

Full source code. Lifetime updates. Polar Merchant-of-Record checkout. Private GitHub repo on purchase.