Velat.
Dark Mode in Next.js: System Detection Without Flash
Engineering2026-09-227 min readBy Velat Studio

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.

Next.js Themes Article

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 — useLayoutEffect still 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.

V

Velat Studio

Systems engineer and Next.js theme builder at Velat. Writing about AI SaaS landing pages, Tailwind CSS templates, and conversion-focused design.

Continue Reading

Why We Built Velat: A Next.js 16 Theme for AI Startups
7 min read

Why We Built Velat: A Next.js 16 Theme for AI Startups

Read
Next.js 16 App Router: What Changed and Why It Matters
8 min read

Next.js 16 App Router: What Changed and Why It Matters

Read
Tailwind CSS Themes: How to Customize Without Breaking Layout
7 min read

Tailwind CSS Themes: How to Customize Without Breaking Layout

Read