
Dark Mode in Next.js: System Detection Without Flash
How to implement dark mode in Next.js 16 with system detection, manual toggle, and no flash of wrong theme on load.
The Flash Problem
Every Next.js developer who has implemented dark mode has seen it. The page loads in light mode for a fraction of a second, then snaps to dark mode once JavaScript runs.
This is called the flash of wrong theme, or FOWT. It's ugly, it's noticeable, and it makes a site feel cheap.
Get the Velat Next.js 16 theme — $49 → https://creativitaz.gumroad.com/l/velat-next-js-ai-landing-page-themes-template
Fixing it requires understanding the order in which things happen during a page load. This article walks through the problem, the naive solution, and the correct one — the same approach used in the Velat theme.
Why the Flash Happens
When a Next.js page loads, the browser does the following, in order:
1. Parses HTML and renders the initial paint
2. Loads and applies CSS
3. Loads and executes JavaScript
4. React hydrates and applies client-side state
If your dark mode is driven by React state (via useState or a context provider), the theme only applies at step 4. The initial paint in step 1 uses whatever the server sent — usually light mode.
That's the flash. The user sees light mode, then dark mode, within about 100 milliseconds.
The Naive Solution (and Why It Fails)
The naive solution is to read localStorage in a useEffect hook:
useEffect(() => {
const saved = localStorage.getItem("theme");
if (saved === "dark") {
document.documentElement.classList.add("dark");
}
}, []);This works, but only after hydration. The flash still happens because useEffect runs after the first paint.
Some developers try to fix this by reading localStorage during render:
const [theme, setTheme] = useState(() => {
if (typeof window !== "undefined") {
return localStorage.getItem("theme") || "light";
}
return "light";
});This causes a hydration mismatch. The server renders with "light", the client renders with "dark", and React throws an error.
The Correct Approach: Inline Script
The correct way to prevent FOWT is to run a small inline script before the page renders. This script reads localStorage and applies the theme class to <html> synchronously.
In Next.js App Router, place this script inside app/layout.tsx, in the <head>:
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
try {
var saved = localStorage.getItem("theme");
var prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
var theme = saved || (prefersDark ? "dark" : "light");
if (theme === "dark") {
document.documentElement.classList.add("dark");
}
} catch (e) {}
})();
`,
}}
/>
</head>
<body>{children}</body>
</html>
);
}The suppressHydrationWarning on <html> is required because the server renders <html> without the dark class, but the client may add it before hydration.
How the Script Works
The script does three things:
1. Reads `localStorage` for a saved theme preference
2. Falls back to system preference if nothing is saved
3. Applies the `dark` class to <html> if needed
Because the script runs synchronously before the first paint, the browser renders the page with the correct theme from the start. No flash.
The try/catch block handles cases where localStorage is blocked (private browsing, strict privacy settings).
The Theme Provider
With the inline script handling initial render, the React theme provider only needs to handle user interaction — toggling the theme and persisting the choice.
In Velat, the theme provider looks like this:
"use client";
import { createContext, useContext, useEffect, useState } from "react";
type Theme = "light" | "dark";
const ThemeContext = createContext({
theme: "light" as Theme,
setTheme: (theme: Theme) => {},
toggleTheme: () => {},
});
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<Theme>("light");
useEffect(() => {
const saved = localStorage.getItem("velat-theme") as Theme | null;
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
const initial = saved || (prefersDark ? "dark" : "light");
setThemeState(initial);
}, []);
const setTheme = (newTheme: Theme) => {
setThemeState(newTheme);
localStorage.setItem("velat-theme", newTheme);
if (newTheme === "dark") {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
};
const toggleTheme = () => {
setTheme(theme === "light" ? "dark" : "light");
};
return (
<ThemeContext.Provider value={{ theme, setTheme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
return useContext(ThemeContext);
}The useEffect syncs the React state with the already-applied class. The setTheme function updates both localStorage and the DOM class.
The Toggle Button
The toggle button is a simple client component:
"use client";
import { Sun, Moon } from "lucide-react";
import { useTheme } from "@/components/theme-provider";
export function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
return (
<button
type="button"
onClick={toggleTheme}
aria-label="Toggle theme"
className="p-2 rounded-full"
>
{theme === "dark" ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</button>
);
}Tailwind Configuration
For Tailwind to apply dark mode styles when the dark class is present on <html>, you need to set the dark mode strategy in tailwind.config.ts:
module.exports = {
darkMode: "class",
// ...
};The default is "media", which only responds to system preference. Setting it to "class" lets you control it manually.
Testing
To verify there's no flash:
1. Set your system to dark mode
2. Clear localStorage for your site
3. Hard reload the page
4. Watch the first paint — it should be dark from the start
If you see a flash of light, the inline script isn't running early enough. Check that it's inside <head>, not inside <body>.
Common Mistakes
A few things that break dark mode:
- ―Placing the script in `<body>` — it needs to run before the first paint, which happens before
<body>content is parsed - ―Using `useLayoutEffect` instead of inline script —
useLayoutEffectstill runs after the first paint - ―Forgetting `suppressHydrationWarning` — React will warn about the class mismatch
- ―Reading `localStorage` during render — causes hydration errors on the server
Why This Matters for Conversion
Dark mode isn't a feature — it's a baseline expectation. Users who prefer dark mode will leave a site that doesn't support it, or that flashes.
More importantly, a flash of wrong theme signals low quality. Visitors notice. It affects trust. And trust affects conversion.
Getting dark mode right is a small detail, but it's the kind of detail that separates a professional site from an amateur one.
Get the Velat Theme
If you want a Next.js 16 theme with dark mode already implemented correctly — no flash, system detection, persistent preference — you can get it now.
Get the Velat Next.js 16 theme — $49 → https://creativitaz.gumroad.com/l/velat-next-js-ai-landing-page-themes-template
One-time payment. Unlimited client projects. Full source code included.
Conclusion
Preventing the flash of wrong theme in Next.js comes down to one thing: an inline script in <head> that runs before the first paint.
Once that's in place, the React theme provider only handles user interaction. The initial render is always correct, whether the user prefers light or dark.
This is one of those details that users never notice when it's done right — and always notice when it's done wrong.


