next-themes Dark Mode Management

Frontend Engineering beginner Building with AI ~15 min read

Overview

Dark mode has become an expected feature of modern web applications. Users want interfaces that respect their system preferences—automatically switching to a dark palette at night or in low-light environments—and they want the ability to override that preference per-site. Implementing this correctly is harder than it appears: you need to read the operating system’s color scheme preference, persist the user’s choice across sessions, apply the theme before the first paint to prevent a flash of the wrong theme, and handle server-side rendering where there is no access to localStorage or window.matchMedia.

The next-themes library solves all of these problems for Next.js applications. It provides a ThemeProvider that wraps the application, a useTheme hook for reading and setting the current theme, and a script injection mechanism that applies the correct theme class to the <html> element before React hydrates. This prevents the dreaded “flash of unstyled content” (FOUC) where the page briefly renders in the wrong theme before JavaScript loads.

The website project uses next-themes to offer light, dark, and system theme options. The theme preference is stored in localStorage and applied via a data-theme attribute on the root element. CSS custom properties switch between color palettes based on this attribute, and the entire design system—backgrounds, text colors, borders, shadows, and syntax highlighting—responds automatically to theme changes without any component-level logic.

Core Concepts

ThemeProvider and Attribute-Based Theming

ThemeProvider from next-themes manages the current theme state and applies it to the document. By default, it sets a data-theme attribute on the <html> element (e.g., <html data-theme="dark">). CSS selectors target this attribute to switch color tokens. The provider can alternatively toggle a class attribute (e.g., class="dark") for compatibility with Tailwind CSS’s dark mode.

System Preference Detection

next-themes listens to the prefers-color-scheme media query to detect the operating system’s theme preference. When the user selects “system” as their theme, the library automatically follows OS-level changes—if the user switches their Mac to dark mode at sunset, the website updates in real time without a page reload.

Flash Prevention with Inline Script

The primary challenge with SSR and dark mode is that the server does not know the user’s preference. Without intervention, the server renders the default theme, and the page flashes when client JavaScript applies the correct theme. next-themes injects a tiny inline script into the <head> that reads localStorage and sets the theme attribute before the browser paints. This script runs synchronously before React hydrates, eliminating the flash entirely.

useTheme Hook

The useTheme hook provides access to the current theme (theme), the resolved theme when set to “system” (resolvedTheme), and a setter function (setTheme). resolvedTheme is particularly useful because it tells you the actual visual theme (“light” or “dark”) regardless of whether the user chose “system.” This is what you should use when rendering theme-dependent content like icons or images.

CSS Custom Properties for Token Switching

Rather than writing separate CSS rules for every themed element, define color tokens as CSS custom properties on the root element and override them per theme. Components use var(--bg-primary) and var(--text-primary), and the theme attribute selector switches the underlying values. This approach scales to hundreds of components without duplicating styles.

How It Works

Setting Up ThemeProvider in Next.js App Router

The provider must wrap the application at the layout level. Because ThemeProvider uses React context and useState, it must be a client component. A thin wrapper component isolates the client boundary.

// components/ThemeProvider.tsx
"use client";

import { ThemeProvider as NextThemesProvider } from "next-themes";

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  return (
    <NextThemesProvider
      attribute="data-theme"      // Sets <html data-theme="dark">
      defaultTheme="system"       // Respect OS preference by default
      enableSystem={true}         // Listen to prefers-color-scheme
      storageKey="app-theme"      // localStorage key for persistence
      disableTransitionOnChange   // Prevent transition flicker on theme switch
    >
      {children}
    </NextThemesProvider>
  );
}

// app/layout.tsx
import { ThemeProvider } from "@/components/ThemeProvider";

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

CSS Custom Property Theme Tokens

Define a complete set of color tokens for each theme. The [data-theme] attribute selector switches all tokens at once when the theme changes.

/* styles/globals.css */
:root,
[data-theme="light"] {
  --bg-primary: #ffffff;
  --bg-secondary: #f8f9fa;
  --bg-card: #ffffff;
  --text-primary: #1a1a2e;
  --text-secondary: #4a4a6a;
  --text-muted: #8888a0;
  --border: #e2e4e8;
  --accent: #6c63ff;
  --accent-secondary: #00b894;
  --shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
  --code-bg: #f4f4f8;
}

[data-theme="dark"] {
  --bg-primary: #0f1117;
  --bg-secondary: #161822;
  --bg-card: #1c1f2e;
  --text-primary: #e8e6f0;
  --text-secondary: #9995b0;
  --text-muted: #6b6785;
  --border: #2a2d40;
  --accent: #6c63ff;
  --accent-secondary: #00d4aa;
  --shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
  --code-bg: #1c1f2e;
}

/* All components use tokens — no theme-specific rules needed */
body {
  background: var(--bg-primary);
  color: var(--text-primary);
}

.card {
  background: var(--bg-card);
  border: 1px solid var(--border);
  box-shadow: var(--shadow);
}

Theme Switcher Component

A theme switcher lets users choose between light, dark, and system themes. The useTheme hook provides the current state and setter. The component must handle the hydration mismatch by deferring render until mounted.

// components/ThemeSwitcher.tsx
"use client";

import { useTheme } from "next-themes";
import { useEffect, useState } from "react";

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

  // Avoid hydration mismatch: server doesn't know the theme
  useEffect(() => setMounted(true), []);

  if (!mounted) {
    // Render placeholder with same dimensions to prevent layout shift
    return <div className="theme-switcher-skeleton" />;
  }

  return (
    <div className="theme-switcher" role="radiogroup" aria-label="Theme">
      {(["light", "dark", "system"] as const).map((option) => (
        <button
          key={option}
          role="radio"
          aria-checked={theme === option}
          className={`theme-option ${theme === option ? "active" : ""}`}
          onClick={() => setTheme(option)}
        >
          {option === "light" && <SunIcon />}
          {option === "dark" && <MoonIcon />}
          {option === "system" && <MonitorIcon />}
          <span>{option.charAt(0).toUpperCase() + option.slice(1)}</span>
        </button>
      ))}
    </div>
  );
}

Theme-Aware Content Rendering

Some content must change based on theme: logo variants, chart colors, embedded images. Use resolvedTheme (not theme) to get the actual visual theme when the user has selected “system.”

// components/Logo.tsx
"use client";

import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import Image from "next/image";

export function Logo() {
  const { resolvedTheme } = useTheme();
  const [mounted, setMounted] = useState(false);

  useEffect(() => setMounted(true), []);

  // During SSR / before mount, render a neutral placeholder
  if (!mounted) {
    return <div className="logo-placeholder" style={{ width: 120, height: 32 }} />;
  }

  const src = resolvedTheme === "dark"
    ? "/images/logo-white.svg"
    : "/images/logo-dark.svg";

  return (
    <Image
      src={src}
      alt="Company Logo"
      width={120}
      height={32}
      priority
    />
  );
}

Tailwind CSS Integration

When using Tailwind CSS, configure next-themes to use the class attribute instead of data-theme, and set Tailwind’s dark mode strategy to class. This enables Tailwind’s dark: variant prefix.

// tailwind.config.ts
import type { Config } from "tailwindcss";

const config: Config = {
  darkMode: "class",   // Use .dark class on <html>
  content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
  theme: {
    extend: {
      colors: {
        background: "var(--bg-primary)",
        foreground: "var(--text-primary)",
      },
    },
  },
};

export default config;

// components/ThemeProvider.tsx — use class attribute
<NextThemesProvider
  attribute="class"        // Sets <html class="dark"> for Tailwind
  defaultTheme="system"
  enableSystem={true}
>
  {children}
</NextThemesProvider>

// Usage in components with Tailwind dark: prefix
// <div className="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100">

Practical Applications

The website project implements a three-option theme switcher (light, dark, system) in the navigation header. The system option is the default, respecting the user’s OS preference out of the box. When a user explicitly selects dark mode, the choice persists in localStorage under the app-theme key and survives browser restarts. The inline script injected by next-themes reads this key and applies the theme before the first paint, so returning users never see a flash.

The documentation site uses CSS custom properties extensively. Syntax-highlighted code blocks, callout boxes, table borders, and even the SVG icons in the sidebar all derive their colors from theme tokens. When the theme switches, every element updates simultaneously through CSS variable inheritance—no JavaScript re-rendering required for the vast majority of the UI.

The design system’s Storybook configuration also integrates next-themes. A toolbar addon lets designers preview every component in both themes. The Storybook decorator wraps stories in ThemeProvider and toggles the theme via a global parameter, ensuring that dark mode edge cases (low-contrast text, invisible borders, wrong logo variant) are caught during component development rather than in production.

Common Pitfalls

Further Reading