Dark Mode in Next.js with next-themes and shadcn/ui
How to add dark mode to a Next.js App Router project with next-themes: install it, wrap the app in a ThemeProvider, wire the Tailwind dark variant, build a toggle that respects the system theme, and fix the hydration mismatch that catches everyone the first time.
Every Next.js project I scaffold gets dark mode in the first ten minutes, and every one of them uses next-themes. It is a small package that does three things well: it sets the theme class on <html> before React hydrates so there is no flash, it persists the choice in localStorage, and it follows the operating system preference when the user has not picked one. This post is the setup I bake into Template-NEXT, written out step by step with the reasons behind each line.
It assumes the App Router, Tailwind CSS v4, and optionally shadcn/ui, with notes where Tailwind v3 or the Pages Router differ.
Install next-themes
npm install next-themesOr with the package manager your project pins:
pnpm add next-themes
yarn add next-themes
bun add next-themesThe current release line is 0.4.x. It ships its own TypeScript types, so there is no @types package to add. If you are on shadcn/ui, note that the shadcn CLI does not install next-themes for you; the dark mode docs expect you to add it yourself.
Wrap the app in a ThemeProvider
ThemeProvider from next-themes uses React context and browser APIs, so it has to be a client component. The root layout is a server component, which is why the usual pattern is a one-file wrapper:
"use client";
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}The wrapper passes every prop through, so the layout stays the place where the configuration lives.
Wire it into the root layout
import { ThemeProvider } from "@/components/theme-provider";
import "./globals.css";
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en" suppressHydrationWarning>
<body className="bg-background text-foreground antialiased">
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</body>
</html>
);
}Each of those props earns its place:
attribute="class"putsclass="dark"(orlight) on<html>. That is what Tailwind's dark variant reads. The alternative,attribute="data-theme", setsdata-theme="dark"instead and suits CSS that keys off data attributes.defaultTheme="system"withenableSystemmeans a first-time visitor gets whatever their OS is set to. WithoutenableSystem, the default falls back tolight.disableTransitionOnChangebriefly disables CSS transitions while the class flips, so a page full oftransition-colorsdoes not fade every element one by one when the user toggles.suppressHydrationWarningon<html>is required, not optional. next-themes injects a tiny inline script that sets the class before hydration, so the server HTML (no class) and the client DOM (class set) legitimately differ on that one element. The flag silences the warning for that element only, not for its children.
The provider must sit in the root layout. If it lives in a nested layout, pages outside that segment render without the theme script and flash on load. The user's choice is stored in localStorage under the key theme; pass storageKey="my-app-theme" if two apps share an origin.
Pages Router: the same provider goes around <Component {...pageProps} /> in pages/_app.tsx. Everything else in this post, including the toggle, is identical.
Tell Tailwind what dark means
Tailwind's dark: variant defaults to prefers-color-scheme, which ignores the class next-themes sets. Point it at the class instead.
Tailwind v4, in your global stylesheet:
@import "tailwindcss";
@custom-variant dark (&:is(.dark *));If you installed shadcn/ui with Tailwind v4, this line is already in the globals.css it generated, along with the light and dark token blocks (:root { ... } and .dark { ... }).
Tailwind v3, in the config file:
import type { Config } from "tailwindcss";
export default {
darkMode: "class",
// ...
} satisfies Config;At this point the theme already works end to end: the OS preference sets the class, the class drives the styles. Everything below is about letting the user override it.
Build a theme toggle
useTheme() is the whole client API. It returns theme (what the user chose, which can be "system"), resolvedTheme (the theme actually applied, always "light" or "dark"), setTheme, systemTheme, and the list of themes.
For a toggle button you want resolvedTheme, because when the user is on "system" you still need to know which way to flip.
"use client";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { MoonIcon, SunIcon } from "lucide-react";
export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) {
return <button aria-hidden className="size-9" />;
}
const isDark = resolvedTheme === "dark";
return (
<button
type="button"
onClick={() => setTheme(isDark ? "light" : "dark")}
aria-label={`Switch to ${isDark ? "light" : "dark"} theme`}
className="grid size-9 place-items-center rounded-md hover:bg-accent"
>
{isDark ? <SunIcon size={17} /> : <MoonIcon size={17} />}
</button>
);
}The mounted guard exists because the server does not know the theme. During server rendering resolvedTheme is undefined, so a component that picks an icon from it would render one icon on the server and another on the client, and React would report a hydration mismatch. Rendering a same-sized placeholder until the effect runs keeps the markup identical on both sides. The placeholder holds the button's footprint so the header does not shift.
Skipping the mounted guard with CSS
If the guard bothers you, render both icons and let CSS pick:
<button type="button" onClick={() => setTheme(isDark ? "light" : "dark")}>
<SunIcon className="hidden dark:block" />
<MoonIcon className="block dark:hidden" />
</button>;The markup is the same regardless of theme, so there is nothing to mismatch, and the icon is correct on the very first paint because the class is already on <html> when the CSS applies. You still need resolvedTheme for the click handler, but a wrong value there before mount only matters if someone clicks within the first few milliseconds. This is the approach the ModeToggle in my design system leans on: it takes isDark and onToggle as props and cross-fades both icons in place, so the app owns the state and the component owns the look.
A three-way selector
When the product wants an explicit "System" option, expose theme instead of resolvedTheme and offer all three:
"use client";
import { useTheme } from "next-themes";
export function ThemeSelect() {
const { theme, setTheme } = useTheme();
return (
<select value={theme} onChange={(e) => setTheme(e.target.value)}>
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
);
}With shadcn/ui, the same idea drops into a DropdownMenu with three items, which is exactly what the shadcn dark mode docs show. If the product needs more than light and dark, pass themes={["light", "dark", "sepia"]} to the provider and give each name a matching class in your CSS; resolvedTheme still resolves system to light or dark.
Forcing a theme
Two cases come up often. A marketing page that is always dark regardless of the user's choice, and a whole site with a single theme where you still want the token system.
For a single page, you do not need the provider at all. The Tailwind variant above matches any element with a .dark ancestor, not only <html>, so wrapping the page in a dark element forces every dark: style inside it:
export default function LaunchPage() {
return <main className="dark bg-background text-foreground">...</main>;
}The user's global choice still applies everywhere else, and the toggle keeps working outside that page.
For the whole site, set forcedTheme on the provider. My portfolio does this: it runs forcedTheme="dark" so the palette stays fixed while every component still reads the same CSS variables as the blog, which lets the two sites share one design system. With forcedTheme set, setTheme still updates storage but the class never changes, so hide the toggle.
Common errors
Hydration failed because the server rendered HTML did not match the client
Almost always the toggle reading theme or resolvedTheme during render without a mounted guard. Add the guard or switch to the CSS-only icon approach above. If the warning points at <html>, you are missing suppressHydrationWarning.
The toggle works but nothing changes colour
The class is on <html> (check in DevTools) but Tailwind is still keyed to the media query. Add the @custom-variant dark line for v4 or darkMode: "class" for v3, then restart the dev server.
The page flashes light before turning dark
The provider is not in the root layout, or a wrapper component is rendering children before the provider mounts. Move ThemeProvider so it wraps everything in app/layout.tsx. Also make sure nothing is stripping inline scripts through a strict Content Security Policy; if you have a nonce, pass it with the nonce prop.
useTheme returns an undefined theme forever
The component calling it is outside the provider tree, typically a component rendered in <head> or in a parallel route slot that has its own layout.
Transitions animate every element on toggle
That is what disableTransitionOnChange prevents. Add it to the provider.
Scrollbars and form controls stay light in dark mode
next-themes sets the color-scheme style on <html> for you as long as enableColorScheme is on, which is the default. If you turned it off, either turn it back on or add color-scheme: dark to your .dark block.
Conclusion
Install the package, wrap the root layout in a client ThemeProvider with attribute="class", teach Tailwind to read the class, and build a toggle that either waits for mount or renders both icons. Those four steps cover the cases that show up in practice, and they are the same four steps Template-NEXT applies when you answer yes to its dark mode prompt.
Have a wonderful day.