P PasteCode
Playbook

Prompt-to-PR: Add Dark Mode

End-to-end SOP for adding flicker-free dark mode to a Next.js Tailwind app using next-themes — class strategy, system preference, and persisted toggle.

CursorClaude CodeCodexWindsurf Next.jsTypeScriptTailwind
.md .json Difficulty: Easy Updated Jun 8, 2026

Dark mode done wrong causes a flash of incorrect theme (FOIT) on every page load. This playbook produces a flicker-free implementation using next-themes with Tailwind’s class strategy.

1. Requirement

Add a persistent dark/light/system mode toggle to a Next.js 15 App Router site using Tailwind. The theme must be:

  • Flicker-free on initial load (no white flash in dark mode).
  • Persisted to localStorage.
  • Defaulting to the OS preference (system).
  • Toggleable via a button in the site header.

2. First Prompt

First Prompt
Add flicker-free dark mode to this Next.js 15 App Router project with Tailwind.
Requirements:
1. Install `next-themes`.
2. In `tailwind.config.ts`, set `darkMode: "class"`.
3. Create `src/components/ThemeProvider.tsx` — a thin Client Component wrapper
around `next-themes` ThemeProvider:
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
Mark it "use client".
4. Wrap the root layout's `<body>` with ThemeProvider in
`src/app/layout.tsx`. Keep the body tag; just wrap children.
5. Create `src/components/ThemeToggle.tsx` — a Client Component with a button
that cycles: light -> dark -> system. Use `useTheme()` from next-themes.
Show the current theme as an icon or label. Handle the mounted check to
avoid hydration mismatch (render null or a skeleton until mounted).
6. Add `<ThemeToggle />` to the existing site header/nav component.
7. Do not change any color values or CSS — only add `dark:` Tailwind variants
where the existing design clearly has background and text colors that need
inversion. Update: bg-white -> bg-white dark:bg-gray-950, text-gray-900
-> text-gray-900 dark:text-gray-100.
Do not invert every element — only top-level layout containers.

3. Expected File Changes

package.json (next-themes)
tailwind.config.ts (darkMode: "class")
src/app/layout.tsx (ThemeProvider wrapper)
src/components/ThemeProvider.tsx (new — "use client" wrapper)
src/components/ThemeToggle.tsx (new — theme cycling button)
src/components/Header.tsx (add ThemeToggle — or whichever nav file)

4. Review Checklist

  • tailwind.config.ts has darkMode: "class" — not "media" (media does not allow a toggle).
  • ThemeProvider is a Client Component ("use client") wrapping children from the Server Component root layout.
  • Root <html> or <body> does NOT have a hardcoded class="dark"next-themes adds this dynamically.
  • ThemeToggle renders null (or a placeholder) until mounted === true to prevent hydration mismatch.
  • No suppressHydrationWarning on <html> is missing — next-themes requires it; confirm it’s present on the <html> tag.
  • dark: variants appear only on layout-level containers, not every single element.
  • localStorage key used by next-themes is the default theme — no custom key that conflicts with other storage.

5. Test Commands

Terminal window
bun dev
# In Chrome DevTools: set prefers-color-scheme to dark
# Reload — confirm no white flash before dark theme applies
# Toggle the button — confirm light/dark/system cycle
# Reload after each — confirm preference persists
bun tsc --noEmit
bun run build
# Confirm no hydration warnings in the browser console after bun run start

6. Common Failures

  • White flash on load (FOIT)ThemeProvider is not wrapping the root layout, or suppressHydrationWarning is missing from <html>. next-themes injects a script in <head> to set the class before paint — this only works when it wraps the outermost element.
  • Hydration mismatch errorThemeToggle renders theme-specific content (icon) before mounted. Add const [mounted, setMounted] = useState(false) and useEffect(() => setMounted(true), []). Return a placeholder until mounted.
  • dark: classes not appliedtailwind.config.ts still has darkMode: "media" or the key is missing. The class strategy requires the "class" value.
  • Toggle not visible<ThemeToggle> imported in a Server Component without "use client" boundary. The import chain must go through a Client Component.

7. Fix Prompt

Fix Prompt
There is a white flash before dark mode applies on page load.
Confirm:
1. In src/app/layout.tsx, the <html> tag has suppressHydrationWarning.
2. ThemeProvider wraps {children} directly inside <body>.
3. ThemeProvider uses attribute="class" (not attribute="data-theme").
next-themes injects an inline script into <head> that reads localStorage and
sets the class on <html> synchronously before paint. This only works if the
ThemeProvider is rendered above the content it styles.

8. PR Description

PR description
## Feature: Flicker-free dark mode via next-themes
- `tailwind.config.ts`: `darkMode: "class"`
- `ThemeProvider` wraps root layout — `next-themes` sets `<html class="dark">`
synchronously before first paint (no flash)
- `ThemeToggle` cycles light / dark / system; persists to `localStorage`
- Mounted guard in ThemeToggle prevents hydration mismatch
- `dark:` variants added to top-level layout containers only