{"slug": "building-a-dark-mode-system-in-next-js-app-router-without-layout-flash", "title": "Building a Dark Mode System in Next.js App Router — Without Layout Flash", "summary": "A developer building a dark mode system for a free AI image generation tool solved the flash of unstyled content (FOUC) in Next.js App Router by using a synchronous inline script in the <head> to apply the theme before React hydrates. The script reads the user's preference from localStorage or system settings and adds a 'dark' class to the document element, preventing the flash. The approach includes suppressHydrationWarning and try/catch to handle React mismatches and browser errors.", "body_md": "Dark mode sounds simple until you implement it. Then you discover the flash.\n\nOn first load, before JavaScript runs, your page renders with the default theme. Then the theme switcher kicks in. For a fraction of a second — sometimes longer on slow connections — users see the wrong theme before it corrects itself.\n\nThis is the flash of unstyled content (FOUC) applied to theming, and it's one of the more annoying UX problems to solve correctly in Next.js App Router.\n\nHere's the approach that works, which I used building the theming system for [a free AI image generation tool](https://pixova.io/blog/free-ai-art-generator).\n\nNext.js App Router renders on the server by default. The server doesn't know the user's theme preference — that's stored in localStorage or a cookie on the client. So the server renders with the default theme, sends that HTML to the browser, and then JavaScript runs and corrects the theme.\n\nThe gap between HTML arriving and JavaScript running = the flash.\n\nThe key insight: to prevent the flash, you need to apply the theme *before* React hydrates. This means a synchronous inline script in the `<head>`\n\nthat reads the preference and applies it immediately.\n\n```\n// app/layout.js\nexport default function RootLayout({ children }) {\n  return (\n    <html lang=\"en\" suppressHydrationWarning>\n      <head>\n        <script\n          dangerouslySetInnerHTML={{\n            __html: `\n              (function() {\n                try {\n                  var theme = localStorage.getItem('theme');\n                  var prefersDark = window.matchMedia(\n                    '(prefers-color-scheme: dark)'\n                  ).matches;\n\n                  if (theme === 'dark' || (!theme && prefersDark)) {\n                    document.documentElement.classList.add('dark');\n                  }\n                } catch (e) {}\n              })();\n            `,\n          }}\n        />\n      </head>\n      <body>{children}</body>\n    </html>\n  );\n}\n```\n\n**Why suppressHydrationWarning?** React will complain that the server-rendered HTML doesn't match the client (because we've added a\n\n`dark`\n\nclass that the server didn't know about). This prop tells React to ignore that mismatch on the `html`\n\nelement specifically.**Why try/catch?** localStorage can throw in certain browser environments (private mode, security policies). Wrapping prevents a JavaScript error from breaking the page.\n\n``` js\n// contexts/ThemeContext.js\n'use client';\nimport { createContext, useContext, useEffect, useState } from 'react';\n\nconst ThemeContext = createContext({\n  theme: 'system',\n  setTheme: () => {},\n  resolvedTheme: 'light',\n});\n\nexport function ThemeProvider({ children }) {\n  const [theme, setThemeState] = useState('system');\n  const [resolvedTheme, setResolvedTheme] = useState('light');\n\n  useEffect(() => {\n    // Read stored preference on mount\n    const stored = localStorage.getItem('theme') ?? 'system';\n    setThemeState(stored);\n  }, []);\n\n  useEffect(() => {\n    const root = document.documentElement;\n\n    if (theme === 'dark') {\n      root.classList.add('dark');\n      setResolvedTheme('dark');\n      localStorage.setItem('theme', 'dark');\n    } else if (theme === 'light') {\n      root.classList.remove('dark');\n      setResolvedTheme('light');\n      localStorage.setItem('theme', 'light');\n    } else {\n      // System preference\n      localStorage.removeItem('theme');\n      const prefersDark = window.matchMedia(\n        '(prefers-color-scheme: dark)'\n      ).matches;\n\n      if (prefersDark) {\n        root.classList.add('dark');\n        setResolvedTheme('dark');\n      } else {\n        root.classList.remove('dark');\n        setResolvedTheme('light');\n      }\n    }\n  }, [theme]);\n\n  // Listen for system preference changes\n  useEffect(() => {\n    if (theme !== 'system') return;\n\n    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');\n    const handler = (e) => {\n      setResolvedTheme(e.matches ? 'dark' : 'light');\n      document.documentElement.classList.toggle('dark', e.matches);\n    };\n\n    mediaQuery.addEventListener('change', handler);\n    return () => mediaQuery.removeEventListener('change', handler);\n  }, [theme]);\n\n  const setTheme = (newTheme) => setThemeState(newTheme);\n\n  return (\n    <ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>\n      {children}\n    </ThemeContext.Provider>\n  );\n}\n\nexport const useTheme = () => useContext(ThemeContext);\njs\n// components/ThemeToggle.jsx\n'use client';\nimport { useTheme } from '@/contexts/ThemeContext';\n\nexport function ThemeToggle() {\n  const { theme, setTheme, resolvedTheme } = useTheme();\n\n  return (\n    <div className=\"flex items-center gap-2\">\n      <button\n        onClick={() => setTheme('light')}\n        className={`p-2 rounded-lg transition-colors ${\n          theme === 'light'\n            ? 'bg-neutral-200 dark:bg-neutral-700'\n            : 'hover:bg-neutral-100 dark:hover:bg-neutral-800'\n        }`}\n        aria-label=\"Light mode\"\n      >\n        ☀️\n      </button>\n\n      <button\n        onClick={() => setTheme('system')}\n        className={`p-2 rounded-lg transition-colors ${\n          theme === 'system'\n            ? 'bg-neutral-200 dark:bg-neutral-700'\n            : 'hover:bg-neutral-100 dark:hover:bg-neutral-800'\n        }`}\n        aria-label=\"System preference\"\n      >\n        💻\n      </button>\n\n      <button\n        onClick={() => setTheme('dark')}\n        className={`p-2 rounded-lg transition-colors ${\n          theme === 'dark'\n            ? 'bg-neutral-200 dark:bg-neutral-700'\n            : 'hover:bg-neutral-100 dark:hover:bg-neutral-800'\n        }`}\n        aria-label=\"Dark mode\"\n      >\n        🌙\n      </button>\n    </div>\n  );\n}\n```\n\nUsing CSS variables with Tailwind's dark mode makes theme-aware colors straightforward:\n\n```\n/* globals.css */\n:root {\n  --background: 255 255 255;\n  --foreground: 15 15 15;\n  --card: 248 248 248;\n  --border: 226 226 226;\n  --muted: 115 115 115;\n}\n\n.dark {\n  --background: 10 10 10;\n  --foreground: 245 245 245;\n  --card: 24 24 24;\n  --border: 38 38 38;\n  --muted: 163 163 163;\n}\n// tailwind.config.js\nmodule.exports = {\n  darkMode: 'class',\n  theme: {\n    extend: {\n      colors: {\n        background: 'rgb(var(--background) / <alpha-value>)',\n        foreground: 'rgb(var(--foreground) / <alpha-value>)',\n        card: 'rgb(var(--card) / <alpha-value>)',\n        border: 'rgb(var(--border) / <alpha-value>)',\n        muted: 'rgb(var(--muted) / <alpha-value>)',\n      },\n    },\n  },\n};\n```\n\nNow you can use `bg-background`\n\n, `text-foreground`\n\n, `border-border`\n\nanywhere in your components and they switch automatically.\n\n**Forgetting suppressHydrationWarning:** React will throw hydration warnings in development. Add it to the\n\n`<html>`\n\nelement specifically — not globally.**Using useEffect to read localStorage on mount:** This still causes a flash, just a shorter one. The inline script approach is necessary to eliminate the flash entirely.\n\n**Not handling the system preference case:** If the user has \"system\" preference and changes their OS theme while on your site, nothing updates unless you listen for `prefers-color-scheme`\n\nchanges.\n\n**Images that don't adapt:** Dark and light mode images need separate handling. Either use CSS filters (`filter: invert(1)`\n\non dark backgrounds) or conditionally render different image sources using `resolvedTheme`\n\n.\n\n```\n// Verify no flash on load\n// 1. Set theme to dark in localStorage\n// 2. Hard refresh — should load dark immediately\n// 3. Clear localStorage — should follow system preference\n// 4. Toggle OS dark mode — should update without refresh (system mode)\n```\n\nThe inline script approach eliminates the flash across all browsers I've tested. The trade-off is a small inline script in every page's `<head>`\n\n— negligible performance impact for the user experience improvement it provides.\n\nThis theming system is running in production on [pixova.io](https://pixova.io/blog/free-ai-art-generator) — the dark/light toggle in the navigation uses exactly this approach. No flash, system preference respected, instant switching.\n\nQuestions about specific edge cases? Comments open.\n\nOne tricky area in App Router: server components don't have access to the client's theme preference. This affects things like server-rendered images or content that should vary by theme.\n\n**Option 1 — CSS-only solution (preferred):**\n\nUse CSS to show/hide variants based on the `.dark`\n\nclass:\n\n```\n/* Show light version by default, dark version when .dark is active */\n.logo-light { display: block; }\n.logo-dark { display: none; }\n\n.dark .logo-light { display: none; }\n.dark .logo-dark { display: block; }\n```\n\nThis requires no JavaScript and works instantly since the `.dark`\n\nclass is already applied by the inline script.\n\n**Option 2 — Client component wrapper:**\n\nWrap theme-sensitive server components in a client component that reads `resolvedTheme`\n\n:\n\n``` js\n'use client';\nimport { useTheme } from '@/contexts/ThemeContext';\n\nexport function ThemedImage({ lightSrc, darkSrc, alt }) {\n  const { resolvedTheme } = useTheme();\n\n  return (\n    <img\n      src={resolvedTheme === 'dark' ? darkSrc : lightSrc}\n      alt={alt}\n    />\n  );\n}\n```\n\nThe downside: this introduces a client boundary just for the image. The CSS approach is cleaner for most cases.\n\nIf you need the server to know the theme (for server-rendered charts, personalized content, or avoiding any flash at all), store the preference in a cookie rather than localStorage.\n\n``` js\n// middleware.js — read theme cookie, add to response\nimport { NextResponse } from 'next/server';\n\nexport function middleware(request) {\n  const theme = request.cookies.get('theme')?.value ?? 'system';\n  const response = NextResponse.next();\n\n  // Pass theme as a header the layout can read\n  response.headers.set('x-theme', theme);\n  return response;\n}\njs\n// app/layout.js — read theme from headers for SSR\nimport { headers } from 'next/headers';\n\nexport default function RootLayout({ children }) {\n  const headersList = headers();\n  const theme = headersList.get('x-theme') ?? 'system';\n  const isDark = theme === 'dark';\n\n  return (\n    <html \n      lang=\"en\" \n      className={isDark ? 'dark' : ''}\n      suppressHydrationWarning\n    >\n      ...\n    </html>\n  );\n}\n```\n\nThis eliminates the flash entirely — even before JavaScript runs — because the server knows the theme. The trade-off: requires a middleware layer and adds a cookie to every request.\n\nFor most use cases, the inline script approach is sufficient and simpler. The cookie approach is worth implementing if you have server-rendered content that needs to match the theme exactly on first load.\n\nThe flash-free dark mode stack in Next.js App Router:\n\n`<head>`\n\nreads preference and applies class before hydration`suppressHydrationWarning`\n\non `<html>`\n\nto suppress React's mismatch warning", "url": "https://wpnews.pro/news/building-a-dark-mode-system-in-next-js-app-router-without-layout-flash", "canonical_source": "https://dev.to/aon_infotech_3a1b6ff525fc/building-a-dark-mode-system-in-nextjs-app-router-without-layout-flash-5gf9", "published_at": "2026-06-21 08:05:41+00:00", "updated_at": "2026-06-21 08:37:22.762982+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Next.js", "React", "pixova.io"], "alternates": {"html": "https://wpnews.pro/news/building-a-dark-mode-system-in-next-js-app-router-without-layout-flash", "markdown": "https://wpnews.pro/news/building-a-dark-mode-system-in-next-js-app-router-without-layout-flash.md", "text": "https://wpnews.pro/news/building-a-dark-mode-system-in-next-js-app-router-without-layout-flash.txt", "jsonld": "https://wpnews.pro/news/building-a-dark-mode-system-in-next-js-app-router-without-layout-flash.jsonld"}}