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 };
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.
-
Server-Side Render (SSR): The server has no access to the browser's
localStorage, wherenext-themespersists 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. -
Client-Side Render: Upon page load, the
next-themesscript executes, reads the "dark" value fromlocalStorage, and updates the theme state. -
React Hydration Process: React then attempts to "hydrate" the server-rendered HTML. During this process, the
ThemeTogglecomponent's code runs on the client.The useThemehook now correctly reports thatresolvedThemeis "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 };
Key Changes:
- A
hasMountedstate is introduced, initialized tofalse. - A
useEffecthook with an empty dependency array runs only on the client after the initial mount, settinghasMountedtotrue. - During SSR and the initial client render (before
useEffectruns), the component renders a placeholder loadingLoader2. This output is consistent between the server and the client, preventing the mismatch. - Once mounted, the component re-renders. Now that
hasMountedis true, it safely displays the correct UI based on the now-reliableresolvedTheme.
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)