Skip to content

Instantly share code, notes, and snippets.

@jvgomg
Created November 30, 2025 18:24
Show Gist options
  • Select an option

  • Save jvgomg/5ae12bdee0bf2c5aa74eda7732366830 to your computer and use it in GitHub Desktop.

Select an option

Save jvgomg/5ae12bdee0bf2c5aa74eda7732366830 to your computer and use it in GitHub Desktop.
Optimal theme-color meta tag integration with next-themes for Next.js App Router

Optimal Theme Color Integration with next-themes

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.

Problem

  1. Browser chrome doesn't update — On iOS and macOS, toggling theme via next-themes doesn't update the browser chrome because theme-color meta tags aren't synced.

  2. Flash on load — Without prefers-color-scheme media queries on the initial meta tags, users see a flash of the wrong color before JS hydrates.

  3. 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.

  4. Navigation resets changes — Next.js App Router replaces meta tags during client-side navigation, overwriting any runtime updates.

Solution

  1. Observer pattern for meta tags — Use MutationObserver to watch for data-theme changes and sync theme-color meta tags. A second observer watches <head> to re-apply changes after navigation.

  2. Injected script in <head> — The sync script runs synchronously before hydration, preventing flash and ensuring immediate updates.

  3. System-first toggle — Toggle between system preference and its opposite, storing "system" rather than an explicit value when returning. This avoids stale overrides.

  4. CSS with fallbacks — Write styles that handle both @media (prefers-color-scheme) and next-themes attribute selectors, ensuring correct appearance before and after JS loads.

Files

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

Integration

  1. Set up ThemeProvider with attribute="data-theme", enableSystem and enableColorScheme. See the next-themes documentation:

    <ThemeProvider 
      attribute="data-theme"
      enableSystem
      enableColorScheme
    >
      {children}
    </ThemeProvider>
  2. Configure colors in theme-config.ts

  3. Add viewport export to your layout:

    export { viewport } from './lib/theme/theme-config'
  4. Add the sync script inside <head>:

    import { ThemeMetaSyncScript } from './lib/theme/theme-meta-sync'
    
    <head>
      <ThemeMetaSyncScript />
    </head>
  5. Use the hook in your toggle component:

    const { mounted, isDark, toggle } = useThemeState()
  6. 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;
    }

See Also

  • 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.
/**
* Theme configuration — single source of truth for theme colors.
*
* Used by both the viewport export (server-rendered meta tags) and
* ThemeMetaSyncScript (client-side updates).
*/
import { jade, jadeDark } from '@radix-ui/colors'
import type { Viewport } from 'next'
/** Maps theme names to their corresponding theme-color meta values. */
export const themeColors: Record<string, string> = {
light: jade.jade3,
dark: jadeDark.jade2,
}
/**
* Next.js viewport export with theme-color meta tags.
*
* Uses media queries to serve the correct color before JS loads.
* Once hydrated, ThemeMetaSyncScript takes over and updates these
* based on the user's actual theme selection.
*/
export const viewport: Viewport = {
themeColor: [
{ media: '(prefers-color-scheme: light)', color: themeColors.light },
{ media: '(prefers-color-scheme: dark)', color: themeColors.dark },
],
}
/**
* Keeps theme-color meta tags in sync with the active theme.
*
* next-themes sets `data-theme` on <html> but doesn't update theme-color meta
* tags. This script observes `data-theme` changes and updates the meta tags
* accordingly. It also watches <head> for mutations because Next.js App Router
* replaces meta tags during navigation, which would otherwise overwrite our changes.
*
* Because we observe `data-theme` (managed by next-themes), this automatically
* responds to OS theme changes when `enableSystem` is enabled on ThemeProvider.
*
* @see https://github.com/pacocoursey/next-themes/issues/78
*/
import { themeColors } from './theme-config'
function themeMetaSync(colors: Record<string, string>) {
const updateThemeColor = () => {
const activeTheme = document.documentElement.getAttribute('data-theme')
if (!activeTheme) return
const expectedColor = colors[activeTheme]
if (!expectedColor) return
const metaTags = document.querySelectorAll('meta[name="theme-color"]')
if (!metaTags.length) return
for (const metaTag of metaTags) {
// Only update if different to avoid infinite loops
if (metaTag.getAttribute('content') !== expectedColor) {
metaTag.setAttribute('content', expectedColor)
}
}
}
// Observer 1: Watch for theme changes (user toggles theme)
const themeObserver = new MutationObserver(updateThemeColor)
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme'],
})
// Observer 2: Watch for <head> changes (Next.js navigation replaces meta tags)
const headObserver = new MutationObserver(updateThemeColor)
headObserver.observe(document.head, {
childList: true,
subtree: true,
})
// Initial sync on page load
updateThemeColor()
}
export const ThemeMetaSyncScript = () => {
return (
<script
dangerouslySetInnerHTML={{
__html: `(${themeMetaSync.toString()})(${JSON.stringify(themeColors)})`,
}}
/>
)
}
'use client'
import { ThemeProvider as NextThemesProvider } from 'next-themes'
export function ThemeProvider({ children }: { children: React.ReactNode }) {
return (
<NextThemesProvider
attribute="data-theme"
enableColorScheme
enableSystem
disableTransitionOnChange // this currently doesn't work (for my setup at least)
>
{children}
</NextThemesProvider>
)
}
'use client'
import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'
/**
* Custom hook that wraps next-themes with a specific toggle behavior:
* - First toggle: Override system preference to the opposite theme
* - Second toggle: Return to system preference
*
* This creates a natural "try the other theme, then go back" experience,
* and avoids persisting a theme override in localStorage when the user
* ends up matching their system preference anyway.
*/
export function useThemeState() {
const { resolvedTheme, setTheme, systemTheme } = useTheme()
const [mounted, setMounted] = useState(false)
// Avoid hydration mismatch
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => setMounted(true), [])
const isDark = resolvedTheme === 'dark'
const toggle = () => {
if (systemTheme === resolvedTheme) {
// Override to the opposite of system preference
setTheme(isDark ? 'light' : 'dark')
} else {
// Return to system preference
setTheme('system')
}
}
return { mounted, isDark, toggle }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment