DEV Community

Cover image for Solving a Hydration Error in Next.js with next-themes
Edgardo Mota
Edgardo Mota

Posted on

Solving a Hydration Error in Next.js with next-themes

When developing applications with Next.js, implementing a theme system (light/dark mode) is a common requirement. The next-themes library is the standard solution for this task, but its interaction with Server-Side Rendering (SSR) can introduce hydration errors if not managed correctly.

This post documents a specific hydration error encountered while developing a theme-switching component and presents the applied solution.

Context and Initial Code

The goal was to create a simple ThemeToggle button that would switch between light and dark themes, displaying a sun or moon icon based on the current state.

The component was initially implemented as follows:

// src/components/ui/ThemeToggle.tsx (Initial version with error)
"use client";

import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";

const ThemeToggle = () => {
  const { setTheme, resolvedTheme } = useTheme();

  const toggleTheme = () => {
    const newTheme = resolvedTheme === "dark" ? "light" : "dark";
    setTheme(newTheme);
  };

  return (
    <Button variant="ghost" size="icon" onClick={toggleTheme}>
      {resolvedTheme === "dark" ? (
        <Sun className="h-[1.2rem] w-[1.2rem]" />
      ) : (
        <Moon className="h-[1.2rem] w-[1.2rem]" />
      )}
      <span className="sr-only">Toggle theme</span>
    </Button>
  );
};

export { ThemeToggle };
Enter fullscreen mode Exit fullscreen mode

The Problem: Conditional Hydration Error

This component functioned correctly during client-side navigation. However, upon reloading the page F5 with the dark theme enabled, a hydration error occurred: Hydration failed because the server rendered HTML didn't match the client.

The error did not occur when reloading the page in the light theme (the system's default).

Root Cause Analysis

The mismatch arises from the state disparity between the server and the client.

  1. Server-Side Render (SSR): The server has no access to the browser's localStorage, where next-themes persists the user's preference. Therefore, the server renders the page using the default theme (in this case, "system", which resolved to "light"). As a result, the HTML sent to the browser contained the <Moon /> icon.
  2. Client-Side Render: Upon page load, the next-themes script executes, reads the "dark" value from localStorage, and updates the theme state.
  3. React Hydration Process: React then attempts to "hydrate" the server-rendered HTML. During this process, the ThemeToggle component's code runs on the client. The useTheme hook now correctly reports that resolvedTheme is "dark", so the component logic determines that the <Sun /> icon should be rendered. At this point, React detects an inconsistency: the server rendered <Moon />, but the client expected to render <Sun />. This triggers the hydration failure.

Solution: Deferring Client-Dependent Rendering

To resolve this mismatch, we must ensure that the initial client-side render matches what the server sent. The strategy is to delay rendering the theme-dependent UI until the component has successfully mounted on the client.

This is achieved using the useState and useEffect hooks.

// src/components/ui/ThemeToggle.tsx (Corrected version)
"use client";

import { useState, useEffect } from "react";
import { Moon, Sun, Loader2 } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";

const ThemeToggle = () => {
  const { setTheme, resolvedTheme } = useTheme();
  const [hasMounted, setHasMounted] = useState(false);

  useEffect(() => {
    setHasMounted(true);
  }, []);

  const toggleTheme = () => {
    const newTheme = resolvedTheme === "dark" ? "light" : "dark";
    setTheme(newTheme);
  };

  if (!hasMounted) {
    return (
      <Button variant="ghost" size="icon" disabled>
        <Loader2 className="h-[1.2rem] w-[1.2rem] animate-spin" />
        <span className="sr-only">Loading...</span>
      </Button>
    );
  }

  return (
    <Button variant="ghost" size="icon" onClick={toggleTheme}>
      {resolvedTheme === "dark" ? (
        <Sun className="h-[1.2rem] w-[1.2rem]" />
      ) : (
        <Moon className="h-[1.2rem] w-[1.2rem]" />
      )}
      <span className="sr-only">Toggle theme</span>
    </Button>
  );
};

export { ThemeToggle };
Enter fullscreen mode Exit fullscreen mode

Key Changes:

  • A hasMounted state is introduced, initialized to false.
  • A useEffect hook with an empty dependency array runs only on the client after the initial mount, setting hasMounted to true.
  • During SSR and the initial client render (before useEffect runs), the component renders a placeholder loading Loader2. This output is consistent between the server and the client, preventing the mismatch.
  • Once mounted, the component re-renders. Now that hasMounted is true, it safely displays the correct UI based on the now-reliable resolvedTheme.

The useState and useEffect pattern provides a solution for aligning server and client renders when dealing with state that is only available in the browser. It prevents hydration errors by ensuring the initial HTML is identical and defers the rendering of dynamic UI until the client's state is known and reliable.

I'm interested to hear about strategies other developers have implemented. What other solutions or custom hooks have you applied to manage hydration errors of this nature in your Next.js projects?

Top comments (0)