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
| Task | Duration |
|---|---|
| next-themes setup + provider | 30 min |
| ThemeToggle component | 30 min |
| Audit existing components for dark: classes | 2-4 hours |
| Fix charts/images | 1 hour |
| Total | ~1 day |
Find boilerplates with dark mode built-in on StarterPick.
Check out this boilerplate
View ShipFast on StarterPick →