Dark Mode Without the Flash in Next.js: A Server-Side Cookie Approach
By Arash Latifi
Implement dark mode in Next.js App Router without the white flash — server-side cookies, no hydration mismatch, RTL and Persian-friendly theming.
TL;DR: Kill the flash by storing the theme in a cookie and reading it on the server —
localStorageplus a client effect always costs you a moment of white screen.
What the flash is and why it hurts
A visitor opens your site, sees white for a fraction of a second, then the page turns dark. That fraction feels cheap and makes the whole product look sloppy. The cause is simple: the theme only lives in the browser (localStorage or a short-lived cookie), so the server knows nothing and renders the default theme. Then useEffect fires and swaps it.
If you've already fought the server/client boundary, RSC poisoning and where use client belongs is the background for this. The durable fix: the theme is a cookie, and cookies are readable on both sides.
The fix: cookie plus a class on <html>
In the App Router you put the theme on html as a class, and read the cookie with cookies() in the same layout. Because this runs on the server, the very first HTML already has the right theme.
And base styles that turn colors into variables:
The dark class goes on html, not body, because some components (modals, tooltips) portal into body and get trapped inside a body-scoped class.
Switching themes and writing the cookie
The toggle must run on the server so the change lands in the next HTML immediately. A Server Action is the simplest path:
And a small client component that only renders the button:
Server theme with no hydration mismatch
If you want first-time visitors to inherit their OS preference, combine the cookie with prefers-color-scheme and a tiny lock script:
Keep both: the cookie for server render, the script for same-frame feedback. If you already use gated routes and middleware, the middleware and auth guide pairs well here, and for cache or PPR setups make sure the layout isn't serving a stale theme — see cache and PPR in Next 15.
The three main ways to know the theme before paint, and what each one costs:
| Approach | Correct on first paint? | Needs client JavaScript | Main drawback |
|---|---|---|---|
| localStorage | No | Yes, to read it | The server cannot see it |
| Cookie plus server render | Yes | Only for toggling | You must reconcile cache policy |
| Inline script before paint | Yes | Yes | Needs a cookie or a server variable |
The simple rule: anything that must be right in the first frame has to be in the initial HTML or in a cookie the server already received.
Small details that matter
- Always set
maxAgeso the cookie doesn't expire between visits. - Transition
backgroundandcolor, neverbackground-image— it's slow on mobile. - Set
color-schemefor both themes so scrollbars and native form controls match. - If you also serve RTL Persian content, define color variables in rem so they scale with the font.
Implementation checklist
- [ ]
themecookie set withpathandmaxAge - [ ] Theme read via
cookies()inlayout.tsxand applied on<html> - [ ] Color variables defined in CSS and filled in for both themes
- [ ] No
localStoragefallback left that reintroduces the flash - [ ] HTML refreshed after a switch (
revalidatePathor dynamic render) - [ ]
color-schemeset for light and dark - [ ] Verified on mobile and desktop that there's no flash
FAQ
Q: Why does localStorage cause the flash?
The server can't read localStorage, so the first HTML uses the default theme and the swap happens after hydration. A cookie is visible on both sides, so the server writes the correct theme in the very first frame.
Q: What about visitors who never sign in?
Guests get a cookie too — the choice is made client-side and persisted by the server on the next response. To sync a theme across devices you have to store it in the database against the user profile.
Q: Is revalidatePath required?
Usually not when the theme lives only in a cookie and every render reads it. But with cache or PPR the cached version can come back with the previous theme, so it's the safety net.
Sources
cookiesreference in the Next.js docs — reading and writing cookies in Server Components and Server Actions- next-themes repository — the library built to solve exactly this flash and hydration mismatch
- Dark Mode in Next.js with next-themes: the clean approach — an independent walkthrough of the same problem
- r/nextjs thread on removing dark mode flickering — community experience and the tradeoffs of older approaches
- React
hydrateRootreference — why a server/client render mismatch is a red flag