Switch Language
Toggle Theme

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:

  1. Read the cookie in middleware and set it in the response header
  2. Render the corresponding theme during server-side rendering based on the response header
  3. 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 darkMode config set to 'class'?
  • Are you correctly using the dark: prefix or .dark selector 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 suppressHydrationWarning to <html>
  • ThemeProvider position is wrong
  • Other scripts are interfering (like Google Analytics)

Issue 3: System theme following doesn’t work

Check:

  • Is enableSystem set to true?
  • Does the browser support prefers-color-scheme? (all modern browsers do)
  • Is the current theme system? (if manually switched, it might be light or dark)

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:

  1. Using next-themes solves Next.js dark mode flickering with zero configuration
  2. Remember to add suppressHydrationWarning to <html>, and mark ThemeProvider as a client component
  3. Set Tailwind’s darkMode config to 'class'
  4. Theme toggle buttons should render after mounting to avoid hydration mismatch
  5. 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?
This is because during SSR, the server doesn't know the user's theme preference, so it renders the default theme. When client-side hydration happens, it reads localStorage and switches themes, causing flicker.

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?
next-themes:
• 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?
In ThemeProvider, set enableSystem={true}, next-themes will automatically detect system theme preference and apply it.

Users can also manually switch themes, and manual switching will override system theme.

Supports three modes: light, dark, system.
Why do I need suppressHydrationWarning?
Because during server-side rendering, the server doesn't know the user's theme preference, so it renders the default theme. When client-side hydration happens, it switches to the user's chosen theme based on localStorage, causing server and client HTML to mismatch.

suppressHydrationWarning tells React this is expected, avoiding warnings.
Why should theme toggle button render after mounted?
To avoid hydration mismatch. Server-side rendering can't know localStorage theme, so if you render the button directly, server and client HTML will be inconsistent, causing React hydration errors.

Rendering after mounted ensures it only renders on the client.
How do I customize theme switching logic?
Use useTheme hook's setTheme method to customize 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?
Default supports light and dark themes. You can also customize more themes through ThemeProvider's themes property.

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

Comments

Sign in with GitHub to leave a comment

Related Posts