Next.js Dark Mode Implementation: Complete next-themes Guide
Honestly, the first time I implemented dark mode in a Next.js project, I got totally wrecked. There was this bright flash when the page loaded, and then it would switch to dark mode—that flickering effect drove me crazy. Users complained in the comments saying “my eyes are getting blinded,” and that’s when I realized how serious this problem was.
After trying several approaches—writing my own solution, using the use-dark-mode library, reading countless tutorials—I finally discovered that next-themes was the real savior. Now all my projects use it: zero flicker, super simple configuration, and perfect system theme support. This article shares all the pitfalls I encountered and the solutions I found.
Why I Ultimately Chose next-themes
Initially, I debated whether to write my own theme switching logic. After all, it’s just reading localStorage and changing a class, right? Seems simple enough. But once I actually got my hands dirty, I realized that Next.js’s server-side rendering characteristics make this deceptively complex.
I tried several approaches:
DIY Solution: The biggest issue was flickering. During SSR, the server doesn’t know the user’s theme preference, so it renders the default light theme. Then during client-side hydration, it reads from localStorage and discovers the user had chosen dark mode, so it switches—causing obvious flickering.
use-dark-mode: This library is actually decent, but it’s not specifically designed for Next.js, so it has some compatibility issues in SSR scenarios.
theme-ui: Very powerful, but too heavy for scenarios that only need dark mode switching. The bundle size is also large.
Finally, I discovered next-themes. With 6000+ GitHub stars, designed specifically for Next.js, zero dependencies, and under 1kb gzipped. The key is it truly achieves zero flicker, has out-of-the-box system theme support, and automatic persistence. TypeScript support is excellent too—really comfortable to use.
Complete Implementation Steps
Installing Dependencies
The usual drill—install the package first:
npm install next-themes
Or if you use pnpm or yarn:
pnpm add next-themes
# or
yarn add next-themes
Creating the ThemeProvider Component
Next, create a Provider component. I usually create a providers or components directory in my project for this kind of stuff.
Create the file providers/theme-provider.tsx:
'use client'
import { ThemeProvider as NextThemesProvider } from 'next-themes'
import { type ThemeProviderProps } from 'next-themes/dist/types'
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}
Note that you must mark this as 'use client' because next-themes needs access to browser APIs. This was my first stumbling block—initially I didn’t add this marker and got a bunch of hydration errors.
Integrating into Layout
Now add the ThemeProvider to your root layout. If you’re using App Router (Next.js 13+), this should be app/layout.tsx:
import { ThemeProvider } from '@/providers/theme-provider'
import './globals.css'
export default function RootLayout({
children,
}: {
children: React.NodeNode
}) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</body>
</html>
)
}
There are several key configurations here—let me explain them one by one:
attribute="class": Tells next-themes to switch themes by modifying the class of the <html> element. Works perfectly with Tailwind CSS’s dark: prefix.
defaultTheme="system": Defaults to following the system theme. First-time visitors will automatically detect the operating system’s theme preference.
enableSystem: Enables system theme detection. This must be turned on, otherwise defaultTheme="system" won’t work.
disableTransitionOnChange: Disables transition animations when switching. You can adjust this based on your needs, but I recommend enabling it because transition animations during dark mode switches mean all elements animate together, which doesn’t look great visually.
suppressHydrationWarning: This goes on the <html> tag and is crucial! Because next-themes modifies the html element’s class before client hydration, React will throw warnings if you don’t add this attribute.
Creating the Theme Toggle Button
With the Provider in place, now we can make a toggle button. Create components/theme-toggle.tsx:
'use client'
import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'
export function ThemeToggle() {
const [mounted, setMounted] = useState(false)
const { theme, setTheme } = useTheme()
useEffect(() => {
setMounted(true)
}, [])
if (!mounted) {
return null
}
return (
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label="Toggle theme"
>
{theme === 'dark' ? '🌞' : '🌙'}
</button>
)
}
There’s a neat trick here: return null before the component finishes loading. Why? Because during server-side rendering, we can’t get theme information. Rendering directly would cause hydration mismatch. Once the client is mounted, useTheme can correctly return the current theme.
If you want a three-state toggle (light / dark / system), you can write it like this:
export function ThemeToggle() {
const [mounted, setMounted] = useState(false)
const { theme, setTheme } = useTheme()
useEffect(() => {
setMounted(true)
}, [])
if (!mounted) return null
const cycleTheme = () => {
if (theme === 'light') setTheme('dark')
else if (theme === 'dark') setTheme('system')
else setTheme('light')
}
const getIcon = () => {
if (theme === 'light') return '🌞'
if (theme === 'dark') return '🌙'
return '💻'
}
return (
<button
onClick={cycleTheme}
className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800"
>
{getIcon()}
</button>
)
}
Deep Dive into the Flickering Issue
Actually, what made me determined to dig into this problem was that annoying flicker. I spent quite a bit of time fully understanding how it works.
How FOUC Happens
FOUC (Flash of Unstyled Content) is particularly common in Next.js dark mode implementations. The root cause is the mismatch between SSR and client-side state.
Think about it—during server-side rendering, the Node.js environment has no window object, can’t access localStorage, and doesn’t know the user’s system theme preference. So the server can only render a default theme (usually light).
Then the HTML is sent to the browser and hydration begins. At this point, React converts the server-rendered static HTML into interactive components. During this process, JavaScript can finally read localStorage, discovers the user previously chose dark theme, and modifies the DOM by adding the dark class.
This modification triggers a re-render, with all styles switching from light to dark—and that’s where the flicker comes from.
next-themes’ Solution
The next-themes solution is clever: it injects a blocking script in the <head>. This script executes before page rendering, immediately reads the theme setting from localStorage, and adds the corresponding class to the <html> element.
The logic looks roughly like this:
(function() {
try {
const theme = localStorage.getItem('theme')
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
const currentTheme = theme || systemTheme
if (currentTheme === 'dark') {
document.documentElement.classList.add('dark')
}
} catch (e) {}
})()
Because this script executes synchronously and blocks page rendering, it guarantees that the correct theme class is set before any content displays. This way, CSS applies the correct styles from the start, naturally eliminating flicker.
Common Configuration Mistakes
I’ve seen many people run into configuration issues, mainly concentrated in these areas:
Forgetting suppressHydrationWarning:
If you forget to add this attribute to the <html> tag, the console will keep showing warnings like:
Warning: Prop `className` did not match. Server: "" Client: "dark"
It doesn’t affect functionality, but it’s annoying.
ThemeProvider in Wrong Position:
Some people put ThemeProvider in a Server Component or outside the body—both cause problems. Remember, ThemeProvider must wrap your page content and must be a Client Component.
Tailwind Configuration Error:
If your tailwind.config.js looks like this:
module.exports = {
darkMode: 'media',
}
That’s definitely problematic. media mode is a pure CSS solution that can only follow system theme and can’t be manually switched. Change it to:
module.exports = {
darkMode: 'class',
}
Theme Persistence and System Following
Persistence Mechanism
By default, next-themes saves your theme choice to localStorage with the key 'theme'. This behavior is automatic—you don’t need to write any extra code.
If you want to customize the storage key, configure it like this:
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
storageKey="my-theme"
>
{children}
</ThemeProvider>
In some scenarios, you might need to use cookies instead of localStorage. For example, if you want the server to know the user’s theme preference to avoid any possible flicker. You can do this:
- Read the cookie in middleware and set it in the response header
- Render the corresponding theme during server-side rendering based on the response header
- Sync cookies and localStorage on the client side
But honestly, for most scenarios, next-themes’ default approach is sufficient.
System Theme Following
The enableSystem configuration allows next-themes to listen for system theme changes. When users switch dark/light mode in their OS settings, if your app’s current theme is system, it will automatically follow the switch.
The underlying implementation listens to the prefers-color-scheme media query:
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (e) => {
// Theme switching logic
})
Users can also manually override the system theme. For example, if the system is in light mode but they switch to dark on your website, next-themes will remember this choice and use dark mode on their next visit.
Multiple Theme Support
While we’re mainly discussing dark mode, next-themes actually supports any number of themes. For instance, you could create purple and green themes:
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
themes={['light', 'dark', 'purple', 'green']}
>
{children}
</ThemeProvider>
Then define corresponding styles in CSS:
.purple {
--background: #f3e8ff;
--foreground: #581c87;
}
.green {
--background: #dcfce7;
--foreground: #14532d;
}
Very flexible when combined with CSS variables.
Practical Tips and Common Issues
Working with Tailwind CSS
If you’re using Tailwind, configuration is even simpler. First make sure tailwind.config.js has:
module.exports = {
darkMode: 'class',
// Other config...
}
Then you can happily use the dark: prefix:
<div className="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
<h1 className="text-2xl font-bold">Heading</h1>
<p className="text-gray-600 dark:text-gray-400">Paragraph text</p>
</div>
Tailwind’s dark: variant activates when the <html> element has the dark class, perfectly matching how next-themes works.
Animation and Transitions
Regarding the disableTransitionOnChange configuration, I personally recommend enabling it. If your CSS has many transition properties, all elements will animate together when switching themes, which looks a bit messy.
But if you really want transition effects, you can do this:
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange={false}
>
{children}
</ThemeProvider>
Then add to your global CSS:
* {
transition: background-color 0.2s ease, color 0.2s ease;
}
This creates a fade effect when switching. Though I’ve tried it a few times and feel no transition is cleaner.
TypeScript Type Support
next-themes has excellent TypeScript support. If you want to extend theme types, you can do this:
import { useTheme } from 'next-themes'
type Theme = 'light' | 'dark' | 'purple'
export function useCustomTheme() {
const { theme, setTheme } = useTheme()
return {
theme: theme as Theme,
setTheme: (theme: Theme) => setTheme(theme),
}
}
This gives you type hints when using it, preventing you from incorrectly setting a non-existent theme.
Troubleshooting Common Issues
Issue 1: Theme switches but styles don’t change
Check these points:
- Is Tailwind’s
darkModeconfig set to'class'? - Are you correctly using the
dark:prefix or.darkselector in CSS? - Check the browser console to see if the
<html>element’s class is correctly added
Issue 2: Page still flickers on refresh
If flickering persists, it might be:
- Forgot to add
suppressHydrationWarningto<html> - ThemeProvider position is wrong
- Other scripts are interfering (like Google Analytics)
Issue 3: System theme following doesn’t work
Check:
- Is
enableSystemset totrue? - Does the browser support
prefers-color-scheme? (all modern browsers do) - Is the current theme
system? (if manually switched, it might belightordark)
Summary
Looking back, from being plagued by flickering issues to now smoothly implementing dark mode, next-themes really saved the day. It doesn’t just solve technical problems—more importantly, it makes the user experience better.
Let’s recap the key points:
- Using
next-themessolves Next.js dark mode flickering with zero configuration - Remember to add
suppressHydrationWarningto<html>, and mark ThemeProvider as a client component - Set Tailwind’s
darkModeconfig to'class' - Theme toggle buttons should render after mounting to avoid hydration mismatch
- System theme following and manual switching can coexist perfectly
If you haven’t tried next-themes in your projects yet, I really recommend giving it a shot. The official docs are also very clear: github.com/pacocoursey/next-themes
Now go add smooth dark mode to your Next.js project! Your users will thank you.
FAQ
Why does the page flicker on load?
next-themes solves this by injecting a script tag during server-side rendering to read the theme early, perfectly solving the problem.
What's the difference between next-themes and other theme libraries?
• Specifically designed for Next.js
• Perfectly solves SSR flickering issues
• Zero dependencies, small size (<1kb)
use-dark-mode:
• Not designed for Next.js
• Has compatibility issues in SSR scenarios
theme-ui:
• Very powerful but too heavy
• Over-engineered for just dark mode switching
How do I implement system theme following?
Users can also manually switch themes, and manual switching will override system theme.
Supports three modes: light, dark, system.
Why do I need suppressHydrationWarning?
suppressHydrationWarning tells React this is expected, avoiding warnings.
Why should theme toggle button render after mounted?
Rendering after mounted ensures it only renders on the client.
How do I customize theme switching logic?
Example: setTheme(theme === 'dark' ? 'light' : 'dark')
You can also directly set specific themes: setTheme('dark'), setTheme('light'), setTheme('system').
Which themes does next-themes support?
Example: themes={['light', 'dark', 'blue', 'green']}
Each theme corresponds to a different CSS class name.
9 min read · Published on: Dec 20, 2025 · Modified on: Jan 22, 2026
Related Posts
Next.js E-commerce in Practice: Complete Guide to Shopping Cart and Stripe Payment Implementation
Next.js E-commerce in Practice: Complete Guide to Shopping Cart and Stripe Payment Implementation
Complete Guide to Next.js File Upload: S3/Qiniu Cloud Presigned URL Direct Upload
Complete Guide to Next.js File Upload: S3/Qiniu Cloud Presigned URL Direct Upload
Next.js Unit Testing Guide: Complete Jest + React Testing Library Setup

Comments
Sign in with GitHub to leave a comment