Skip to main content

Add Dark Mode to Any SaaS Boilerplate 2026

·StarterPick Team
Share:

TL;DR

Dark mode in Next.js is 30 minutes with next-themes + Tailwind. The hard part is avoiding the flash of unstyled content (FOUC) and handling hydration correctly. This guide covers the production-ready implementation pattern used by most SaaS boilerplates.


Why Dark Mode Matters for SaaS Products

Dark mode is no longer a nice-to-have — it's a baseline expectation for developer-facing and productivity SaaS products. The macOS, Windows, and Linux dark mode APIs are widely adopted, and users who prefer dark mode find it jarring when a tool forces them into light mode.

The practical impact: dark mode affects conversion and retention for products used in the evening or in low-light environments (which includes most developer tools and content creation apps). It's also a signal of product quality — SaaS products without dark mode look unpolished compared to competitors that support it.

The good news: adding dark mode to a Next.js + Tailwind app takes about half a day, not a redesign. The next-themes library handles the hard parts (system preference detection, flash prevention, SSR hydration). Your job is mostly adding dark: prefixes to your Tailwind classes.


Boilerplates with Dark Mode Built-In

Before building from scratch, check your boilerplate:

BoilerplateDark ModeImplementation
Makerkitnext-themes + shadcn/ui
Supastarternext-themes + shadcn/ui
Open SaaSnext-themes
ShipFastDaisyUI themes
T3 Stack❌ Add it
Bedrocknext-themes

If you're on shadcn/ui (Makerkit, Supastarter, Bedrock), dark mode CSS variables are already in your globals.css. You just need the toggle component and to ensure next-themes is wired up.


Setup

npm install next-themes
// app/providers.tsx
'use client';
import { ThemeProvider } from 'next-themes';

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <ThemeProvider
      attribute="class"          // Adds 'dark' class to <html>
      defaultTheme="system"      // Follow OS preference by default
      enableSystem               // Detect system preference
      disableTransitionOnChange  // Prevent flash during theme change
    >
      {children}
    </ThemeProvider>
  );
}
// app/layout.tsx
import { Providers } from './providers';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

The suppressHydrationWarning attribute on <html> is required. Without it, React throws a hydration error because the server renders with one theme and the client immediately switches to the user's preference. This attribute tells React to skip the hydration check for this element.


Tailwind Configuration

// tailwind.config.js
module.exports = {
  darkMode: 'class', // Enabled by 'dark' class on <html>
  // ...
};

Using Dark Mode in Components

// Tailwind dark: prefix for dark mode styles
<div className="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
  <h1 className="text-2xl font-bold">Dashboard</h1>
  <p className="text-gray-500 dark:text-gray-400">
    Welcome back
  </p>
  <div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
    <button className="bg-indigo-600 hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 text-white px-4 py-2 rounded">
      Action
    </button>
  </div>
</div>

Theme Toggle Component

// components/ThemeToggle.tsx
'use client';
import { useTheme } from 'next-themes';
import { useEffect, useState } from 'react';
import { SunIcon, MoonIcon } from 'lucide-react';

export function ThemeToggle() {
  const { theme, setTheme } = useTheme();
  const [mounted, setMounted] = useState(false);

  // Avoid hydration mismatch — only render after mount
  useEffect(() => setMounted(true), []);
  if (!mounted) return <div className="w-9 h-9" />; // Placeholder to avoid layout shift

  return (
    <button
      onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
      className="rounded-lg p-2 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800"
      aria-label="Toggle dark mode"
    >
      {theme === 'dark' ? (
        <SunIcon className="h-5 w-5" />
      ) : (
        <MoonIcon className="h-5 w-5" />
      )}
    </button>
  );
}

System + Manual Options

// components/ThemeSelector.tsx — 3-way toggle (light/dark/system)
'use client';
import { useTheme } from 'next-themes';
import { useEffect, useState } from 'react';

export function ThemeSelector() {
  const { theme, setTheme } = useTheme();
  const [mounted, setMounted] = useState(false);

  useEffect(() => setMounted(true), []);
  if (!mounted) return null;

  const options = [
    { value: 'light', label: 'Light' },
    { value: 'dark', label: 'Dark' },
    { value: 'system', label: 'System' },
  ];

  return (
    <div className="flex rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
      {options.map(option => (
        <button
          key={option.value}
          onClick={() => setTheme(option.value)}
          className={`px-3 py-1.5 text-sm ${
            theme === option.value
              ? 'bg-gray-900 text-white dark:bg-white dark:text-gray-900'
              : 'text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800'
          }`}
        >
          {option.label}
        </button>
      ))}
    </div>
  );
}

Avoiding Common Pitfalls

1. Flash of Unstyled Content (FOUC)

The suppressHydrationWarning on <html> and mounted check in the toggle component prevent FOUC. Do not conditionally render themes in CSS — next-themes handles this via an inline script that runs before React hydrates.

2. Images in Dark Mode

// For logos/images that need to swap in dark mode:
<img
  src="/logo-light.png"
  alt="Logo"
  className="dark:hidden"
/>
<img
  src="/logo-dark.png"
  alt="Logo"
  className="hidden dark:block"
/>

// Or use CSS filter for simple darkening:
<img
  src="/logo.png"
  alt="Logo"
  className="dark:invert dark:brightness-200"
/>

3. Charts and Data Visualizations

// Pass theme-aware colors to chart libraries
import { useTheme } from 'next-themes';

function Chart({ data }) {
  const { resolvedTheme } = useTheme();
  const isDark = resolvedTheme === 'dark';

  const colors = {
    grid: isDark ? '#374151' : '#E5E7EB',
    text: isDark ? '#9CA3AF' : '#6B7280',
    primary: '#6366F1', // Indigo works on both
  };

  return <LineChart colors={colors} data={data} />;
}

Shadcn/ui Dark Mode

If your boilerplate uses shadcn/ui, dark mode CSS variables are already defined. Just ensure:

// tailwind.config.js already has:
darkMode: ['class'],
/* globals.css already has: */
.dark {
  --background: 222.2 84% 4.9%;
  --foreground: 210 40% 98%;
  /* ... */
}

With shadcn/ui, you rarely need custom dark: classes — most components use the CSS variables that automatically flip in dark mode.


Persisting Theme Preference

next-themes persists the user's theme preference in localStorage automatically. No additional setup required. The theme is restored on the next visit without a server round-trip.

If you want the theme preference server-side (for SSR personalization), store it in a cookie via a server action when the user changes their theme. Then read the cookie in your root layout to apply the correct theme class before hydration, eliminating any flash entirely.

Dark Mode Design Considerations

Purely inverting colors from light to dark produces poor results. Dark mode is not "white text on black background" — it uses dark gray backgrounds (not pure black) because pure black causes eye strain and halo effects on OLED screens.

The standard dark mode palette in 2026:

  • Background: gray-900 (#111827) or slate-900 (#0f172a)
  • Card/surface: gray-800 (#1f2937)
  • Border: gray-700 (#374151)
  • Text primary: gray-100 (#f3f4f6)
  • Text secondary: gray-400 (#9ca3af)
  • Accent: Keep your primary brand color — most work on both dark and light backgrounds

Tailwind's dark variants map perfectly to this palette. Systematic use of Tailwind's gray scale across light and dark produces consistent, accessible results without a custom design system.


Testing Dark Mode

Test dark mode systematically before launch:

  • Toggle between light/dark/system in each major section of your app
  • Test with system preference set to dark in OS settings
  • Verify no pure white elements are missing dark: styles (search for bg-white without dark:bg-)
  • Check that form inputs, modals, dropdowns, and tooltips all have dark variants
  • Verify OG images and email templates (which can't use CSS dark mode) are designed for one mode

A useful grep: grep -r "bg-white" src/components/ | grep -v "dark:" — this finds white backgrounds without dark alternatives.


CSS Custom Properties: The Right Abstraction

Rather than sprinkling dark: classes everywhere, the maintainable approach for complex UIs is CSS custom properties (variables) for semantic color tokens. Shadcn/ui uses this pattern — the components reference --background, --foreground, --primary etc., and the values switch at the :root[class="dark"] level.

/* globals.css */
:root {
  --background: 0 0% 100%;
  --foreground: 222.2 84% 4.9%;
  --card: 0 0% 100%;
  --card-foreground: 222.2 84% 4.9%;
  --primary: 222.2 47.4% 11.2%;
  --muted: 210 40% 96.1%;
  --muted-foreground: 215.4 16.3% 46.9%;
  --border: 214.3 31.8% 91.4%;
}

.dark {
  --background: 222.2 84% 4.9%;
  --foreground: 210 40% 98%;
  --card: 222.2 84% 4.9%;
  --card-foreground: 210 40% 98%;
  --primary: 210 40% 98%;
  --muted: 217.2 32.6% 17.5%;
  --muted-foreground: 215 20.2% 65.1%;
  --border: 217.2 32.6% 17.5%;
}

The benefit: updating your color system means updating variables in one place, not hunting for dark: class pairs throughout your components. When you want to change your brand color, you update --primary and it propagates everywhere.

If you're on a boilerplate that doesn't use shadcn/ui, consider adding CSS custom properties for your brand colors and semantic tokens as you build the dark mode layer. The upfront work saves time in every future design iteration.


Dark Mode for Emails and OG Images

Two content types that CSS dark mode cannot touch: transactional emails and Open Graph preview images. Both are rendered outside the user's browser (in an email client or link preview), so prefers-color-scheme CSS has no effect.

Emails: Design transactional emails for light mode. Dark-mode-aware email design requires separate email templates per theme and detecting the user's preference server-side to send the appropriate version — not worth the complexity for most SaaS products. Instead, use a light background (#ffffff or #f8f9fa) with dark text that's readable in both light and dark email clients. Avoid pure white (#fff) or pure black (#000) — they clash with dark-mode email clients that invert colors.

OG images: Open Graph images (og:image) are served from a URL and displayed in link previews by Slack, Twitter, iMessage, etc. These can't respond to dark mode. Design OG images with sufficient contrast for both light and dark preview environments. A light background with dark text is universally readable.

For dynamic OG image generation (based on page content), Vercel's @vercel/og library generates images server-side. Design templates in the light palette and don't attempt dark mode variants.


Accessibility: Dark Mode Is Not Enough

Dark mode improves comfort for many users, but accessibility requires more than just dark backgrounds. The combination of dark mode and accessibility compliance:

Contrast ratios: WCAG AA requires 4.5:1 contrast ratio for normal text and 3:1 for large text. Your dark mode palette must meet this standard. The gray scales in Tailwind's default palette are WCAG-compliant when used in the combinations they're designed for (gray-900 backgrounds with gray-100 text), but mixing arbitrary grays can fail. Test with the browser's built-in accessibility checker.

Focus indicators: Dark mode focus rings need higher contrast than light mode rings. A focus:ring-indigo-500 that's visible on white may be invisible on a dark gray background. Ensure your focus ring colors have sufficient contrast in both themes.

System preference respect: Beyond the toggle, the initial theme should respect prefers-color-scheme: dark. The next-themes defaultTheme="system" setting handles this. Users who set their OS to dark mode shouldn't see a light-mode flash on first load.


For boilerplates that include dark mode from the start, see best boilerplates for AI SaaS products — all modern AI-focused starters include dark mode by default. If you're adding dark mode as part of a larger ShipFast customization, the ShipFast customization guide covers the full customization workflow. For the design system foundation, React Server Components boilerplates covers modern RSC-compatible UI patterns.


Typography Adjustments for Dark Mode

Dark mode creates subtle typographic challenges. Text that looks sharp and legible at a given font weight on white backgrounds can appear slightly bolder on dark — the higher contrast between text and background changes how font weight renders optically.

The practical fix: reduce font weight slightly for body text in dark mode. If font-medium (500) is your body weight on light, font-normal (400) on dark delivers equivalent perceived legibility. Headings rarely need adjustment because large type renders well at any weight.

Also watch for text-gray-500 used as secondary/muted text. On a light background, text-gray-500 is readable but visually secondary. On a dark gray background (gray-800), it becomes nearly invisible. The correct dark mode equivalent for secondary text is dark:text-gray-400 — check every text-gray-500 in your components.


Time Budget

TaskDuration
next-themes setup + provider30 min
ThemeToggle component30 min
Audit existing components for dark: classes2-4 hours
Fix charts/images1 hour
Total~1 day

One day of investment delivers a dark mode experience that users notice and appreciate — the perceived quality uplift is disproportionate to the effort, especially for developer tools and productivity SaaS where a significant portion of users have dark mode set as their OS default.

Find boilerplates with dark mode built-in on StarterPick.

Check out this boilerplate

View ShipFaston StarterPick →

The SaaS Boilerplate Matrix (Free PDF)

20+ SaaS starters compared: pricing, tech stack, auth, payments, and what you actually ship with. Updated monthly. Used by 150+ founders.

Join 150+ SaaS founders. Unsubscribe in one click.