Utilities for integrating next-themes with Next.js App Router to provide a clean light and dark theme switching experience. Currently handles light and dark themes, but could be extended to support multiple themes.
-
Browser chrome doesn't update — On iOS and macOS, toggling theme via
next-themesdoesn't update the browser chrome becausetheme-colormeta tags aren't synced. -
Flash on load — Without
prefers-color-schememedia queries on the initial meta tags, users see a flash of the wrong color before JS hydrates. -
Stale localStorage overrides — A user toggles to dark mode in the morning, then toggles back. If the second toggle sets
theme: "light"instead of clearing the override, they return later that evening locked to light mode instead of following their system preference. -
Navigation resets changes — Next.js App Router replaces meta tags during client-side navigation, overwriting any runtime updates.
-
Observer pattern for meta tags — Use
MutationObserverto watch fordata-themechanges and synctheme-colormeta tags. A second observer watches<head>to re-apply changes after navigation. -
Injected script in
<head>— The sync script runs synchronously before hydration, preventing flash and ensuring immediate updates. -
System-first toggle — Toggle between system preference and its opposite, storing
"system"rather than an explicit value when returning. This avoids stale overrides. -
CSS with fallbacks — Write styles that handle both
@media (prefers-color-scheme)andnext-themesattribute selectors, ensuring correct appearance before and after JS loads.
| File | Purpose |
|---|---|
theme-config.ts |
Theme colors and Next.js viewport export |
theme-state.ts |
useThemeState hook wrapping next-themes |
theme-meta-sync.tsx |
Inline script keeping meta tags in sync |
-
Set up ThemeProvider with
attribute="data-theme",enableSystemandenableColorScheme. See the next-themes documentation:<ThemeProvider attribute="data-theme" enableSystem enableColorScheme > {children} </ThemeProvider>
-
Configure colors in
theme-config.ts -
Add viewport export to your layout:
export { viewport } from './lib/theme/theme-config'
-
Add the sync script inside
<head>:import { ThemeMetaSyncScript } from './lib/theme/theme-meta-sync' <head> <ThemeMetaSyncScript /> </head>
-
Use the hook in your toggle component:
const { mounted, isDark, toggle } = useThemeState()
-
Write CSS with fallbacks for pre-JS and post-JS states:
/* Before JS loads: respect OS preference */ @media (prefers-color-scheme: dark) { :root:not([data-theme]) { --bg: #000; } } /* After JS: next-themes attribute takes over */ [data-theme="dark"] { --bg: #000; }
- next-themes#72 — Discussion on using sessionStorage instead of localStorage, which would eliminate stale override issues.
- next-themes#78 — Ongoing work to add native theme-color meta syncing to next-themes, which would replace the need for this code.