The login page went blank in production. Not a broken layout, not a missing stylesheet. Blank: the whole subtree that the sidebar belonged to just disappeared from the DOM right after the page finished loading. It only happened on a real production build (next build && next start), never in next dev, and only when the stored theme preference was "dark".
The cause was a theme toggle.
If you've worked with the Next.js App Router, you already know the usual shape of a dark-mode toggle: some client state holding "light" or "dark", an effect that writes it onto the DOM, a button that flips it. You've probably also met the classic symptom that comes with getting this wrong: a flash of the wrong theme for a frame or two on load. What you might not know, since it's a narrower topic, is Trusted Types: a browser security feature that helps prevent DOM-based XSS by blocking untrusted strings from being written into dangerous sinks like innerHTML, unless an approved policy sanctions them. If a Trusted Types policy is enforced and something tries to write raw HTML through a path the policy didn't approve, the browser throws instead of rendering it. Keep that in your pocket. It's the reason this bug was a crash and not just a flicker.
This is Atlas, a project-management app I built solo (real Postgres row-level security, an append-only activity log written entirely by database triggers, accessibility treated as a requirement rather than a feature). It's not running at scale with real traffic. It doesn't need to be. Not for this story. What it has is a CI pipeline, a test suite, and one bug I had to actually diagnose, not simulate for a tutorial.
Here's what caused it, why the fix everyone reaches for first doesn't actually fix it, and what does.
The disagreement under the flash
"Flash of wrong theme" undersells what's happening. It's not really a timing problem. It's a disagreement. Next.js renders your app to HTML on the server, where there's no window, no localStorage, no way to know what a returning visitor picked last time. React then hydrates that HTML on the client, attaching event handlers and reconciling its idea of the tree against what's already in the DOM. If the client's first render produces different output than the server did, React has a mismatch on its hands.
In Atlas, that mismatch traced back to one function: the lazy initializer inside ThemeContext's useState call.
const [theme, setTheme] = useState<Theme>(() => {
if (typeof window === "undefined") return "light";
const stored = localStorage.getItem("atlas-theme");
if (stored === "light" || stored === "dark") return stored;
// Defer to system preference if no stored theme, and persist that choice.
const system = window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
localStorage.setItem("atlas-theme", system);
return system;
});Read that typeof window check carefully, because the bug is hiding in what it doesn't guard. On the server, window is genuinely undefined, so this returns "light", always. But on the client, this initializer doesn't just run once at some safe point after mount. It runs during React's very first client render, the hydration render, and by then window already exists. So the client's first render skips the "light" fallback entirely and reads localStorage immediately, returning whatever the user actually had stored.
Server HTML gets built from the forced "light" branch. But on the client, that first render reads whatever was actually stored. If your stored theme was "dark", the two disagree on the very first paint, before React has done anything else.
Why it was a crash, not a flicker
Most of the time, a hydration mismatch is just visually annoying: React discards the mismatched subtree and regenerates it client-side, and you get a flash. In Atlas, Sidebar.tsx was the one component whose render output actually branched on theme (the icon, the label, the button), so it was the one place this showed up at all.
But React's mismatch-recovery path writes the corrected markup through a raw innerHTML call, and Atlas runs a Trusted Types policy in app/layout.tsx that only defines createScriptURL (needed for the app's own chunk loader), not createHTML. Recovery hit a browser API it wasn't authorized to use, and threw. In production, with the policy actually enforced, that meant the sidebar's subtree didn't recover. The failure took the rest of the screen with it. It just stayed blank. I'm not going to get into the design of that Trusted Types policy here. That's a separate topic. What matters for this piece is the consequence: a hydration mismatch that would be a cosmetic flash almost anywhere else was a blank screen in production.
The tempting fix, and where it actually falls short
The standard answer to "my render depends on something the server can't know" is a mounted flag. It's not from Atlas. It's just the shape most of us reach for:
function ThemeIcon({ theme }: { theme: 'light' | 'dark' }) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return <IconPlaceholder />;
}
return theme === 'light' ? <Moon /> : <Sun />;
}Give this a fair hearing, because it does solve the mismatch. Server and client both render the placeholder on the first pass, since mounted starts false in both places. No disagreement, no crash. The real icon only shows up once useEffect fires after mount, which is client-only by definition.
Here's where it stops being a fix and starts being a workaround. It costs an extra render, since flipping mounted triggers a second pass. It trades a wrong-icon flash for a placeholder-then-pop-in flash (better, but still a flash). And it doesn't touch the actual problem: React still believes it owns this value. The flag is a manual "don't trust yourself yet" gate bolted onto the read side, and you have to remember to bolt it on again at every component that needs the theme. Extract that pattern into a shared hook, which you inevitably will once you have two or three call sites, and you're one small step from useSyncExternalStore anyway, just without the guarantee it actually gives you.
The deeper issue: theme was never really React's state to own in the first place.
Where the theme actually lives
By the time React starts hydrating anything, the correct theme is already sitting on the DOM. Atlas sets it with an inline script in the root layout, written as a raw <script> tag via dangerouslySetInnerHTML (not next/script, which would load too late to beat the paint):
(function(){try{var t=localStorage.getItem('atlas-theme');if(t==='light'||t==='dark'){document.documentElement.setAttribute('data-theme',t);}else{var d=window.matchMedia('(prefers-color-scheme: dark)').matches;document.documentElement.setAttribute('data-theme',d?'dark':'light');}}catch(e){}})();It reads the stored preference, falls back to matchMedia if there isn't one, and sets data-theme on document.documentElement. The whole thing is wrapped in a try/catch, so a blocked localStorage (private browsing, strict cookie settings) just skips silently instead of breaking the page. This runs before React hydrates anything. data-theme is correct on the <html> element from the first paint.
React just doesn't know that. Nothing has told it. And that's the actual bug: not when the value becomes available, but which system owns it. The theme lives in the DOM. React needs to read an external source, not maintain its own parallel copy of the same fact.
That's exactly what useSyncExternalStore is for.
The implementation
"use client";
import { useSyncExternalStore } from "react";
export type DisplayedTheme = "light" | "dark" | "pending";
function subscribe(callback: () => void) {
const observer = new MutationObserver(callback);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme"],
});
return () => observer.disconnect();
}
function getSnapshot(): DisplayedTheme {
return document.documentElement.getAttribute("data-theme") === "dark"
? "dark"
: "light";
}
function getServerSnapshot(): DisplayedTheme {
return "pending";
}
export function useDisplayedTheme(): DisplayedTheme {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}Three functions, each doing one job. getSnapshot doesn't hold state. It reads data-theme straight off documentElement, which means it's always reporting what's actually there, not what React last remembered setting. subscribe sets up a MutationObserver scoped tightly with attributeFilter: ["data-theme"], so it only fires when that one attribute changes, not on every DOM mutation in the document. And getServerSnapshot is where the interesting decision lives, but I'll get to that in a moment.
Notice what useDisplayedTheme doesn't do: it doesn't write anything. Atlas keeps write and read on two separate paths on purpose. ThemeContext still owns the toggle:
function toggleTheme() {
const next: Theme = theme === "light" ? "dark" : "light";
setTheme(next);
localStorage.setItem("atlas-theme", next);
}Clicking the button updates React's own theme state and writes the choice to localStorage, both inside toggleTheme itself. A useEffect elsewhere in ThemeContext then reflects that updated state onto data-theme. But Sidebar.tsx, the component that actually renders based on the theme, doesn't read theme from that context at all anymore. It reads useDisplayedTheme():
const { toggleTheme } = useTheme();
const displayedTheme = useDisplayedTheme();
const isThemePending = displayedTheme === "pending";Write goes through the context's state, because a click handler needs somewhere to hold intent. Read goes through the DOM, because the DOM is what's actually true, including on that first render where React's own state hasn't caught up yet. Two different sources, chosen for two different jobs.
The honest third state
getServerSnapshot returning "pending" instead of guessing "light" is the detail that makes the rest of this actually work, not just look tidy.
During hydration, useSyncExternalStore calls getServerSnapshot and only getServerSnapshot, confirmed in React 19.2.4's own source. It never touches getSnapshot on that first pass. So the server renders "pending", and the client's first render also produces "pending", by construction, since both sides call the exact same function. There's no way for these two to disagree, because they're not two guesses that happen to agree. They're the same fixed answer, sourced from the same place, every time. No mismatch, no recovery path, no innerHTML write for Trusted Types to reject.
The real theme shows up afterward, once the observer's getSnapshot takes over, and that's an ordinary re-render triggered by a normal state update, not an error being recovered from. Sidebar.tsx uses isThemePending to make that in-between moment honest instead of invisible:
<button
onClick={toggleTheme}
disabled={isThemePending}
aria-busy={isThemePending}
aria-label={
isThemePending
? "Loading theme preference"
: displayedTheme === "light"
? "Switch to dark mode"
: "Switch to light mode"
}
>A mounted flag's placeholder state is a UI trick, something to look at while the real thing loads. "pending" is a real value in the type (DisplayedTheme = "light" | "dark" | "pending"), one the button's disabled and aria-busy attributes respond to directly. The type system knows there are three possible states here, not two, so nothing downstream can quietly pretend the answer is always known.
What this costs, and when it isn't worth it
There was a cleaner option on the table: read the theme from a cookie on the server and pass it down, which removes even the brief "pending" window entirely. It got rejected. cookies() unconditionally opts a route into dynamic rendering, and several of Atlas's routes are static today. Giving that up wasn't worth it. It would mean restructuring the dashboard layout out of being a single client component, just to remove a sub-100ms loading state. That's a call that could reasonably go the other way in a different app. It's not a universal rule, just the one that fit here.
More broadly, none of this is worth reaching for unless two things are both true: your app has state set on the DOM before React hydrates, and something's render output actually branches on that state. If your theme only drives CSS custom properties and nothing in your JSX conditionally renders based on it, there's no mismatch to have in the first place. data-theme can just sit there and your styles read it, no hook required.
The mainstream approach is to let the theme drive CSS (a class or data attribute on the html element that Tailwind or your stylesheet reads) and keep your JSX theme-agnostic. Most theme-dependent UI, including a toggle icon, is better handled that way, often by rendering both states and swapping them with CSS. I branched on the theme in JavaScript for one reason: the toggle's aria-label ("Switch to dark mode" versus "Switch to light mode") is an accessible name, and you cannot set an accessible name from a CSS class. Accessibility was a first-class requirement in Atlas, not a coat of paint, so the toggle had to announce its actual state to assistive tech. Once one thing in that component genuinely needed the theme as a JavaScript value, reading it correctly was the real problem, and that is what useSyncExternalStore solves. Sidebar.tsx needed this because it was the one place in Atlas where a component's actual output, not just its appearance, depended on a value React didn't have yet. That's the specific condition to look for, not "I have a theme toggle."