{
  "id": "add-dark-mode",
  "type": "playbooks",
  "category": "playbooks",
  "locale": "en",
  "url": "/playbooks/add-dark-mode",
  "title": "Prompt-to-PR: Add Dark Mode",
  "description": "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.",
  "tools": [
    "Cursor",
    "Claude Code",
    "Codex",
    "Windsurf"
  ],
  "stack": [
    "Next.js",
    "TypeScript",
    "Tailwind"
  ],
  "tags": [
    "nextjs",
    "tailwind",
    "typescript"
  ],
  "difficulty": "easy",
  "updated": "2026-06-08",
  "markdown": "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.\n\n## 1. Requirement\n\nAdd a persistent dark/light/system mode toggle to a Next.js 15 App Router site using Tailwind. The theme must be:\n- Flicker-free on initial load (no white flash in dark mode).\n- Persisted to `localStorage`.\n- Defaulting to the OS preference (`system`).\n- Toggleable via a button in the site header.\n\n## 2. First Prompt\n\n```txt title=\"First Prompt\"\nAdd flicker-free dark mode to this Next.js 15 App Router project with Tailwind.\n\nRequirements:\n1. Install `next-themes`.\n2. In `tailwind.config.ts`, set `darkMode: \"class\"`.\n3. Create `src/components/ThemeProvider.tsx` — a thin Client Component wrapper\n   around `next-themes` ThemeProvider:\n     <ThemeProvider attribute=\"class\" defaultTheme=\"system\" enableSystem>\n       {children}\n     </ThemeProvider>\n   Mark it \"use client\".\n4. Wrap the root layout's `<body>` with ThemeProvider in\n   `src/app/layout.tsx`. Keep the body tag; just wrap children.\n5. Create `src/components/ThemeToggle.tsx` — a Client Component with a button\n   that cycles: light -> dark -> system. Use `useTheme()` from next-themes.\n   Show the current theme as an icon or label. Handle the mounted check to\n   avoid hydration mismatch (render null or a skeleton until mounted).\n6. Add `<ThemeToggle />` to the existing site header/nav component.\n7. Do not change any color values or CSS — only add `dark:` Tailwind variants\n   where the existing design clearly has background and text colors that need\n   inversion. Update: bg-white -> bg-white dark:bg-gray-950, text-gray-900\n   -> text-gray-900 dark:text-gray-100.\n   Do not invert every element — only top-level layout containers.\n```\n\n## 3. Expected File Changes\n\n```txt\npackage.json                          (next-themes)\ntailwind.config.ts                    (darkMode: \"class\")\nsrc/app/layout.tsx                    (ThemeProvider wrapper)\nsrc/components/ThemeProvider.tsx      (new — \"use client\" wrapper)\nsrc/components/ThemeToggle.tsx        (new — theme cycling button)\nsrc/components/Header.tsx             (add ThemeToggle — or whichever nav file)\n```\n\n## 4. Review Checklist\n\n- `tailwind.config.ts` has `darkMode: \"class\"` — not `\"media\"` (media does not allow a toggle).\n- `ThemeProvider` is a Client Component (`\"use client\"`) wrapping `children` from the Server Component root layout.\n- Root `<html>` or `<body>` does NOT have a hardcoded `class=\"dark\"` — `next-themes` adds this dynamically.\n- `ThemeToggle` renders `null` (or a placeholder) until `mounted === true` to prevent hydration mismatch.\n- No `suppressHydrationWarning` on `<html>` is missing — `next-themes` requires it; confirm it's present on the `<html>` tag.\n- `dark:` variants appear only on layout-level containers, not every single element.\n- `localStorage` key used by `next-themes` is the default `theme` — no custom key that conflicts with other storage.\n\n## 5. Test Commands\n\n```bash\nbun dev\n\n# In Chrome DevTools: set prefers-color-scheme to dark\n# Reload — confirm no white flash before dark theme applies\n\n# Toggle the button — confirm light/dark/system cycle\n# Reload after each — confirm preference persists\n\nbun tsc --noEmit\nbun run build\n# Confirm no hydration warnings in the browser console after bun run start\n```\n\n## 6. Common Failures\n\n- **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.\n- **Hydration mismatch error** — `ThemeToggle` renders theme-specific content (icon) before `mounted`. Add `const [mounted, setMounted] = useState(false)` and `useEffect(() => setMounted(true), [])`. Return a placeholder until mounted.\n- **`dark:` classes not applied** — `tailwind.config.ts` still has `darkMode: \"media\"` or the key is missing. The class strategy requires the `\"class\"` value.\n- **Toggle not visible** — `<ThemeToggle>` imported in a Server Component without `\"use client\"` boundary. The import chain must go through a Client Component.\n\n## 7. Fix Prompt\n\n```txt title=\"Fix Prompt\"\nThere is a white flash before dark mode applies on page load.\n\nConfirm:\n1. In src/app/layout.tsx, the <html> tag has suppressHydrationWarning.\n2. ThemeProvider wraps {children} directly inside <body>.\n3. ThemeProvider uses attribute=\"class\" (not attribute=\"data-theme\").\n\nnext-themes injects an inline script into <head> that reads localStorage and\nsets the class on <html> synchronously before paint. This only works if the\nThemeProvider is rendered above the content it styles.\n```\n\n## 8. PR Description\n\n```md title=\"PR description\"\n## Feature: Flicker-free dark mode via next-themes\n\n- `tailwind.config.ts`: `darkMode: \"class\"`\n- `ThemeProvider` wraps root layout — `next-themes` sets `<html class=\"dark\">`\n  synchronously before first paint (no flash)\n- `ThemeToggle` cycles light / dark / system; persists to `localStorage`\n- Mounted guard in ThemeToggle prevents hydration mismatch\n- `dark:` variants added to top-level layout containers only\n```"
}