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-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. -
Client-Side Render: Upon page load, the
next-themes
script 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
ThemeToggle
component's code runs on the client.The useTheme
hook now correctly reports thatresolvedTheme
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 };
Key Changes:
- A
hasMounted
state is introduced, initialized tofalse
. - A
useEffect
hook with an empty dependency array runs only on the client after the initial mount, settinghasMounted
totrue
. - During SSR and the initial client render (before
useEffect
runs), 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
hasMounted
is 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)