Skip to main content

How to Add Dark Mode to Any SaaS Boilerplate (2026)

·StarterPick Team
dark-modenextjstailwinduiguide2026

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.


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>  {/* suppressHydrationWarning prevents React error */}
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

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 is already configured. Just ensure:

// tailwind.config.js already has:
darkMode: ['class'],

// CSS variables in globals.css already have:
.dark {
  --background: 222.2 84% 4.9%;
  --foreground: 210 40% 98%;
  // ...
}

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

Find boilerplates with dark mode built-in on StarterPick.

Check out this boilerplate

View ShipFast on StarterPick →

Comments