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
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
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.tshasdarkMode: "class"— not"media"(media does not allow a toggle).ThemeProvideris a Client Component ("use client") wrappingchildrenfrom the Server Component root layout.- Root
<html>or<body>does NOT have a hardcodedclass="dark"—next-themesadds this dynamically. ThemeTogglerendersnull(or a placeholder) untilmounted === trueto prevent hydration mismatch.- No
suppressHydrationWarningon<html>is missing —next-themesrequires it; confirm it’s present on the<html>tag. dark:variants appear only on layout-level containers, not every single element.localStoragekey used bynext-themesis the defaulttheme— no custom key that conflicts with other storage.
5. Test Commands
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 --noEmitbun run build# Confirm no hydration warnings in the browser console after bun run start6. Common Failures
- White flash on load (FOIT) —
ThemeProvideris not wrapping the root layout, orsuppressHydrationWarningis missing from<html>.next-themesinjects a script in<head>to set the class before paint — this only works when it wraps the outermost element. - Hydration mismatch error —
ThemeTogglerenders theme-specific content (icon) beforemounted. Addconst [mounted, setMounted] = useState(false)anduseEffect(() => setMounted(true), []). Return a placeholder until mounted. dark:classes not applied —tailwind.config.tsstill hasdarkMode: "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
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 andsets the class on <html> synchronously before paint. This only works if theThemeProvider is rendered above the content it styles.8. 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