Repository navigation
Implementing a light/dark mode toggle with app router + RSC #53063
Replies: 11 comments 25 replies
|
This meets your requirements 1-4 (and maybe 5), as long as your theme CSS can be controlled by
import { cookies } from 'next/headers';
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
const cookieStore = cookies();
const theme = cookieStore.get('theme'));
return (
<html lang="en" data-theme={theme?.value}>
<body>
<ThemeToggle initialValue={theme?.value as ('light' | 'dark')} />
{children}
</body>
</html>
);
}
'use client';
import { useEffect, useState } from 'react';
type Theme = 'light' | 'dark';
function ThemeToggle({ initialValue }: { initialValue: Theme }) {
const [theme, setTheme] = useState(initialValue);
useEffect(() => {
if (theme) {
document.cookie = `theme=${theme};path=/;`;
document.querySelector('html').setAttribute('data-theme', theme);
} else {
setTheme(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
}
}, [theme]);
return (
<button type="button" onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
Toggle Theme
</button>
);
}
export default ThemeToggle;I'm not sure if it is the best way but it worked for me. The main problem to be overcome is that when the user picks a theme it needs to be known on the server to avoid the potential FOUC when This overcomes that, the main problem I guess is whether using a cookie for it is the "right" way, or maybe you need to avoid cookies. There's one caveat I noticed which relates to your 5th requirement, which is present in my actual use case but not in the demo above: when You could return null or some loading state until the effect runs and the theme has a value to avoid that. |
|
Example Live Site URL : https://nextjs-app-darkmode.vercel.app/ This solution by Dan Abramov meets most of the requirements.
const code = function () {
window.__onThemeChange = function () {};
function setTheme(newTheme) {
window.__theme = newTheme;
preferredTheme = newTheme;
document.documentElement.dataset.theme = newTheme;
window.__onThemeChange(newTheme);
}
var preferredTheme;
try {
preferredTheme = localStorage.getItem('theme');
} catch (err) {}
window.__setPreferredTheme = function (newTheme) {
setTheme(newTheme);
try {
localStorage.setItem('theme', newTheme);
} catch (err) {}
};
var darkQuery = window.matchMedia('(prefers-color-scheme: dark)');
darkQuery.addEventListener('change', function (e) {
window.__setPreferredTheme(e.matches ? 'dark' : 'light');
});
setTheme(preferredTheme || (darkQuery.matches ? 'dark' : 'light'));
};
export const getTheme = `(${code})();`;
import './globals.css';
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import { getTheme } from '../lib/getTheme';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'Create Next App',
description: 'Generated by create next app',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang='en'>
<head>
<script dangerouslySetInnerHTML={{ __html: getTheme }} />
</head>
<body className={inter.className}>{children}</body>
</html>
);
}
'use client';
import { useState, useEffect } from 'react';
const SetTheme = () => {
const [theme, setTheme] = useState(global.window?.__theme || 'light');
const isDark = theme === 'dark';
const toggleTheme = () => {
global.window?.__setPreferredTheme(theme === 'light' ? 'dark' : 'light');
};
useEffect(() => {
global.window.__onThemeChange = setTheme;
}, []);
return <button onClick={toggleTheme}>{isDark ? 'dark' : 'light'}</button>;
};
export default SetTheme;
import dynamic from 'next/dynamic';
const SetTheme = dynamic(() => import('../components/SetTheme'), {
ssr: false,
});
export default function Home() {
return (
<main>
...
<SetTheme />
...
</main>
);
}
:root {
color-scheme: light;
...
}:root[data-theme='dark'] {
color-scheme: dark;
...
}Reference:
|
|
@Hugomndez Thanks for sharing this. The idea of exporting the getTheme function as a string was brilliant. |
|
Great implementation for now... |
|
@Hugomndez thanks for this. |
|
None of these solutions are "clean", they all either happen too late or they all use suppressHydrationWarning (which is less than ideal). 😞 Hoping for a cleaner solution from the Next.js team. 🍻 |
|
Using the cookies answer above as a base, I think I found a relatively simple solution to this issue, adding in React Context. We can include a Context Provider in the layout and still have all the children be server or client components, as only the children that consume the context need to be client components. Without context, my toggle button would flicker on page load and route changes. I'm using data-theme and CSS variables with Tailwind to manage the styles, and I'm sourcing the initial theme from my CMS (overriding this if a user has visited the site and chosen their theme). Full solution and example site here: https://www.mandalinedev.com/articles/color-themes-next-js // src/app/layout.tsx
export default async function RootLayout({ children }: RootLayoutProps ) {
const client = createClient();
const settings = await client.getSingle("settings");
const cookieStore = cookies();
const theme = cookieStore.get('theme');
return (
<html
lang="en"
className={`${nunito_sans.className} ${fira_mono.className}`}
data-theme={theme?.value ?? settings.data.theme}
>
<body className="overflow-x-hidden antialiased">
<main>
<ThemeProvider initialTheme={theme?.value ?? settings.data.theme}>
{children}
</ThemeProvider>
</main>
</body>
</html>
);
}// src/app/theme-provider.tsx
'use client';
import { createContext, useState, ReactNode, useEffect } from 'react';
import { setCookie } from 'cookies-next';
interface ThemeContextType {
theme: string;
setTheme: (theme: string) => void;
}
export const ThemeContext = createContext<ThemeContextType>({
theme: 'Turquoise',
setTheme: () => {},
});
interface ThemeProviderProps {
children: ReactNode;
initialTheme: string;
}
export default function ThemeProvider({
children,
initialTheme
}: ThemeProviderProps) {
const [theme, setThemeState] = useState(initialTheme);
const setTheme = (newTheme: string) => {
setThemeState(newTheme);
setCookie('theme', newTheme, { path: '/', maxAge: 7 * 24 * 60 * 60 });
document.documentElement.setAttribute("data-theme", newTheme);
};
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}// ThemeToggle.tsx
'use client';
import { useContext } from "react";
import { ThemeContext } from "@/app/theme-provider";
const themes = ["Turquoise", "Violet", "Purple"];
const ThemeSelector = () => {
const { theme: selectedTheme, setTheme } = useContext(ThemeContext);
const handleThemeChange = (theme: string) => {
setTheme(theme);
};
const classNames = (...classes: string[]) => {
return classes.filter(Boolean).join(' ');
};
return (
<div>
<nav className="flex space-x-4" aria-label="Tabs">
{themes.map((theme) => (
<button
key={theme}
onClick={() => handleThemeChange(theme)}
className={classNames(
selectedTheme === theme
? 'text-highlight-light bg-secondary-dark'
: 'text-primary-light hover:text-highlight-light',
'transition-colors rounded-lg px-4 py-1 text-sm tracking-tight'
)}
aria-current={selectedTheme === theme}
>
{theme}
</button>
))}
</nav>
</div>
);
};
export default ThemeSelector; |
|
There's an abstraction worth considering that deals mostly with all of these requirements. Here https://www.npmjs.com/package/next-themes |
|
Hi everyone, I wanted to share a simplified version of how I ended up implementing a light/dark mode toggle in a Next.js App Router + RSC project, inspired by next-app-theme but adapted to use //
// 1) SessionThemeScript.tsx
//
// Inline script that runs in <head> to set the theme from sessionStorage
// (or system preference if nothing is stored).
//
// src/components/SessionThemeScript.tsx
import React from "react";
type Theme = "dark" | "light";
declare global {
interface Window {
__theme: Theme;
__onThemeChange: (theme: Theme) => void;
__setPreferredTheme: (theme: Theme) => void;
}
}
const code = function () {
// Declare sessionStorageKey inside the function.
const sessionStorageKey = "__PREFERRED_THEME__";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
window.__onThemeChange = function (theme) {};
function setTheme(newTheme: Theme) {
if (window.__theme) {
document.documentElement.classList.remove(window.__theme);
}
window.__theme = newTheme;
document.documentElement.dataset.theme = newTheme;
window.__onThemeChange(newTheme);
document.documentElement.classList.add(newTheme);
}
let preferredTheme: Theme | null = null;
try {
preferredTheme = sessionStorage.getItem(sessionStorageKey) as Theme | null;
console.log("Read preferredTheme from sessionStorage:", preferredTheme);
} catch (_err) {
console.error("Error reading sessionStorage:", _err);
}
window.__setPreferredTheme = function (newTheme: Theme) {
setTheme(newTheme);
try {
sessionStorage.setItem(sessionStorageKey, newTheme);
console.log("Updated sessionStorage with theme:", newTheme);
} catch (_err) {
console.error("Error setting sessionStorage:", _err);
}
};
const darkQuery = window.matchMedia("(prefers-color-scheme: dark)");
darkQuery.addEventListener("change", function (e) {
window.__setPreferredTheme(e.matches ? "dark" : "light");
});
const computedTheme: Theme = preferredTheme || (darkQuery.matches ? "dark" : "light");
console.log("Computed theme:", computedTheme);
setTheme(computedTheme);
if (!preferredTheme) {
try {
sessionStorage.setItem(sessionStorageKey, computedTheme);
console.log("Stored computed theme in sessionStorage:", computedTheme);
} catch (_err) {
console.error("Error storing computed theme in sessionStorage:", _err);
}
}
};
const getTheme = `(${code})();`;
export function SessionThemeScript() {
return <script dangerouslySetInnerHTML={{ __html: getTheme }} />;
}
//
// 2) useTheme.ts
//
// A custom hook that reads the current theme from window.__theme,
// provides a toggle function, and re-reads sessionStorage on route changes.
//
// src/lib/use-theme.ts
|
"use client"
import { useCallback, useEffect, useState } from "react"
import { usePathname } from "next/navigation"
export function useTheme() {
const pathname = usePathname()
const [theme, setTheme] = useState(
typeof window === "undefined" ? "light" : window.__theme || "light",
)
const toggleTheme = useCallback(() => {
// Read the current theme directly from the global variable.
const current = window.__theme || "light"
const newTheme = current === "light" ? "dark" : "light"
console.log("Toggling theme: current =", current, ", new =", newTheme)
// Use the fallback mechanism for setting the theme.
if (typeof window.__setPreferredTheme === "function") {
window.__setPreferredTheme(newTheme)
} else {
console.warn("window.__setPreferredTheme is not a function")
sessionStorage.setItem("__PREFERRED_THEME__", newTheme)
document.documentElement.setAttribute("data-theme", newTheme)
}
// Force a re-read from sessionStorage immediately after toggling.
try {
const stored = sessionStorage.getItem("__PREFERRED_THEME__")
if (stored) {
console.log("After toggle, stored theme =", stored)
setTheme(stored as "light" | "dark")
} else {
// Fallback if nothing is stored.
setTheme(newTheme)
}
} catch (err) {
console.error("Error reading sessionStorage after toggle:", err)
setTheme(newTheme)
}
}, [])
useEffect(() => {
// Register callback so that whenever the inline script updates the theme, we update our local state.
window.__onThemeChange = newTheme => {
console.log("Global theme changed to:", newTheme)
setTheme(newTheme)
}
}, [])
// On every route change, re-read sessionStorage.
useEffect(() => {
if (typeof window !== "undefined") {
const stored = sessionStorage.getItem("__PREFERRED_THEME__")
if (stored && stored !== theme) {
console.log("Route change: updating theme from sessionStorage:", stored)
if (typeof window.__setPreferredTheme === 'function') {
window.__setPreferredTheme(stored as "light" | "dark")
} else {
console.warn("window.__setPreferredTheme is not a function")
document.documentElement.setAttribute("data-theme", stored)
}
setTheme(stored as "light" | "dark")
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname])
return { theme, toggleTheme }
}
//
// 3) ThemeToggle.tsx
//
// A toggle component that calls the hook to switch
// between light and dark modes.
//
// src/components/ThemeToggle.tsx
"use client"
import { useEffect, useState } from "react"
import { Moon, Sun } from "lucide-react"
import { useTheme } from "@/lib/theme/use-theme"
import { Button } from "./ui/button"
export default function ThemeToggle() {
const { theme, toggleTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [theme])
if (!mounted) return null
const icon = theme === "dark" ? <Sun size={18} className="text-zinc-800 dark:text-off-white" /> : <Moon size={18} className="text-zinc-800 dark:text-off-white" />
return (
<Button
onClick={toggleTheme}
aria-label="Toggle Dark Mode"
className="hover:scale-105 transition-all duration-300 cursor-pointer"
variant="outline-no-bg"
size="sm"
>
{icon}
</Button>
)
}
//
// 4) "Locale Switcher" snippet
//
// Before switching locales, store the current theme in sessionStorage,
// then force a full page reload so the inline script re-applies the theme.
//
"use client";
import { useTransition } from "react";
import { setUserLocale } from "@/i18n/action"
import { useLocale } from "next-intl";
import { useRouter } from "next/navigation";
export function LocaleSwitcher() {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const currentLocale = useLocale();
function handleChange(value: string) {
if (value === currentLocale || isPending) return
if (typeof window !== "undefined" && window.__theme) {
sessionStorage.setItem("__PREFERRED_THEME__", window.__theme)
}
startTransition(async () => {
await setUserLocale({ id: value } as Locale)
const currentUrl = new URL(window.location.href)
const pathSegments = currentUrl.pathname.split("/").filter(Boolean)
if (pathSegments.length > 0) {
pathSegments[0] = value
} else {
pathSegments.push(value)
}
currentUrl.pathname = "/" + pathSegments.join("/")
window.location.assign(currentUrl.toString())
router.refresh()
})
}
return (
<select onChange={(e) => handleChange(e.target.value)} value={currentLocale}>
<option value="en">English</option>
<option value="ar">Arabic</option>
{/* etc. */}
</select>
);
}
//
// 5) A `ClientOnly` component
//
// Injects the inline theme script into the <head>—the theme gets applied
// on first load (and re-applied when locales change).
// src/components/ClientOnly.tsx
"use client";
import React, { useState, useEffect } from "react";
export function ClientOnly({ children }: { children: React.ReactNode }) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
return <>{children}</>;
}
//
// 6) The root layout
//
// src/app/layout.tsx
// SessionThemeScript is injected into <head>
// ClientOnly wraps the children for client-side rendering
import { SessionThemeScript } from "@/components/SessionThemeScript";
import ClientOnly from "@/components/ClientOnly"
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<SessionThemeScript />
</head>
<body>
<ClientOnly>
{children}
</ClientOnly>
</body>
</html>
);
} |
|
I've shared an example of an application that works well for me. I'm not sure if there's a better approach, but this solution works smoothly for now: |
|
The issue with a lot of these solutions is that the server does not entirely know the client’s preferences (which is both the chosen theme AND the system pref). So the server HTML, the bootstrap step, and the hydrated app all start from different assumptions, and that is where the flash / mismatch / weird toggle state comes from. The setup that has worked best for me is:
That means that, aside from the first load, the client and server have the exact same starting point and can render the same content. You don’t need any weird hacks to prevent flashing of theme pickers. I ended up extracting that into The basic shape is: const initial = parseThemeCookie((await headers()).get(‘cookie’));
<html suppressHydrationWarning {...registerTheme(initial)}>
<head>
<script id="theme-script">{themeScript()}</script>
</head>
<body>
<ThemeProvider initial={initial}>{children}</ThemeProvider>
</body>
</html> |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
It is extremely difficult to implement a light/dark mode toggle with app router + RSC. People are stumbling over every piece of the puzzle and there are numerous issues/discussions about the problems the different approaches have. If this is already possible, it would be great if we could get an official example to serve as a reference implementation. And if not, could we get the bugs fixed that block such a reference implementation?
The requirements that make this hard are as follows:
display:noneuntil the React component renders. The only JS that blocks render should be the few lines of JS we inline into the<head>that sets the light/dark mode attribute on html.Here's a summary of the issues I'm aware of:
<head>but I don't see a way to do that with the app router.<Script>does not run early enough, not even withbeforeInteractive. There are threads where people have claimed that<script>will load at the bottom of head, but I am not observing that. In short: how do we reliably inject a few lines of JS into the head, in both production and development builds? Example threads:beforeInteractivestrategy ignores additional attributes in app router #49830suppressHydrationWarningwhich feels like a pretty heavy hammer and can potentially cause us to miss other real issues that get suppressed. Example threads:ssr:falsemakes it easy to write an isomorphic component, but the component loads too late and there are bugs with dynamically loaded content.There are a lot of solutions floating around but I haven't found one that actually meets the requirements above. For example:
Accept-CHheader, but that doesn't work on first load and isn't supported by Firefox/Safari.All reactions