Perfect Next.js dark mode in 2 lines of code. Support System preference and any other theme with no flashing
by pacocourseyLast 12 weeks Β· 0 commits
2 of 6 standards met
What happened? Summary When using next-themes 0.4.6 with Next.js 16 (cacheComponents: true) + React 19, switching locales causes a hidden ThemeProvider to reapply its stale theme when it becomes active again. The UI and localStorage end up out of sync and the theme flips back to the previous value. ### Environment next-themes: 0.4.6 Next.js: 16.0.3 (App Router) React: 19.2.0 cacheComponents: true (Activity enabled) next-intl: 4.5.3 Tailwind: 4.1.x ### Repro steps 1. In , set . 2. Wrap the app with from next-themes in a Providers component (standard setup: , , , , ). 3. Route structure with locales: , (or any content). 4. Use any two locales (e.g., , ) via or similar. 5. Run dev server, open . 6. Sequence: Toggle theme to Dark. Switch locale to ES. Toggle theme to Light. Switch locale back to EN. 7. Observe: theme flips back to Dark (the stale value). localStorage may show Light, but the DOM class flips to the older value when the hidden tree is reactivated. ### Expected The theme should remain at the last user selection (Light after the sequence), with DOM class and localStorage in sync. ### Actual When returning to the previous locale, the hidden ThemeProvider instance preserved by Activity (cacheComponents) re-applies its stale theme, flipping the UI. Multiple ThemeProviders appear to mount/unmount; their effects each write to storage/DOM, causing oscillation. ### Notes / Hypothesis React 19 + Next 16 Activity keeps previous route trees βhiddenβ instead of unmounted. next-themes assumes a single live provider; hidden instances still run effects and write the theme (DOM class + storage) when they become active again. With cacheComponents disabled (so Activity is off), the bug disappears. A custom single-writer theme manager that applies the class directly and listens to storage events fixes the issue. Mitigations could be: Make next-themes Activity-aware (single-writer or guard DOM/storage writes when hidden). Key ThemeProvider per locale to force unmount on locale change, or provide an option to opt out of reapplying state from hidden trees. Document incompatibility with cacheComponents/Activity for now. ### Minimal reproduction (shape) Next.js 16 app with + Providers with next-themes ThemeProvider wrapping the app A simple language switcher using locale routing and a theme switcher calling Sequence above reliably reproduces in dev Version 0.4.6 What browsers are you seeing the problem on? Firefox
Repository: pacocoursey/next-themes. Description: Perfect Next.js dark mode in 2 lines of code. Support System preference and any other theme with no flashing Stars: 6213, Forks: 236. Primary language: TypeScript. Languages: TypeScript (100%). License: MIT. Homepage: https://next-themes-example.vercel.app/ Topics: dark-mode, dark-theme, nextjs, react, themes. Latest release: v0.4.6 (11mo ago). Open PRs: 9, open issues: 42. Last activity: 1w ago. Community health: 42%. Top contributors: pacocoursey, trm217, BlankParticle, dependabot[bot], brunocrosier, 0xflotus, Unsleeping, amrhassab, andreacassani, arturbien and others.
TypeScript
Follow up to https://github.com/pacocoursey/next-themes/pull/377 - Adds an example with Cache Components on, and e2e config for it. Making it separate because, #377 is not good just for Activity or whatever, I think it has its own merit, but also because adding an example with next@16 changes the root lock file. The tests are expected to fail until #377 lands, though I might need to look into the unit test failed run.
Hi, Opening this a draft for now. This PR switches from useState+useEffects to a useSyncExternalStore approach. The value itself is now part of a external store. The is also read via store synchronization+subscription. And the is derived as you render. is what the context provider makes available downstream, and it itself calls , which notifies all listeners, and updates the localStorage. I also made sure to add the event to the store subscription. And since the theme used to be React state, lack of local storage worked fine, but in this approach I use a Map to maintain the state. The one remaining effect is the sync with the DOM effect. So what we have is React subscribes to two stores, theme and system theme, which we use to resolve a theme, and sync the DOM with it via applyTheme. The theme store exposes a method to update and notify React, which also save to localStorage. All unit and e2e tests pass, and I added one more for localStorage not being available somehow (Brave with shields all the way up, etc). Even so, I am running the examples manually and trying it out myself, you never know, this is a big change all things considered. Additionally, this makes the ThemeProvider resilient under Activity hide/reveal cycles. Where a ThemeProvider preserves its local theme value, and on reveal overrides whatever other ThemeProvider might have set. I have a follow up draft with and example and e2e for this use case.