
Next.js Preventing flash before hydration
There's a specific category of UI bug that only exists because of server rendering, and it trips up almost everyone the first time they hit it: anything that depends on client-only state — a user's locale, their saved theme preference, an accordion section they left expanded — literally cannot be known by the server. The server has to render something, so it renders a reasonable default, and then the browser has to reconcile that default against the truth once it actually knows the client's real state. Handled naively, that reconciliation is visible: a flash of the wrong theme, or worse, an outright React hydration error.
This article covers the one technique that actually solves this cleanly — an inline <script> that runs synchronously as the browser parses HTML, correcting the DOM before the very first paint — and walks through it for the three cases where it matters most: dates, themes, and persisted UI state.
Why this is harder than it looks
Three approaches get reached for instinctively, and all three have a real cost:
A Client Component that formats client-specific values directly causes an actual hydration error, because the server rendered one value (server locale, server timezone) and the client re-executes the same code and produces a different one. Deferring the client-specific logic into useEffect avoids the error, but introduces a visible flash — the wrong value renders first, then gets swapped after mount. And rendering everything server-side only, using server defaults unconditionally, avoids both problems by simply giving up on client-specific formatting altogether.
The fix that avoids all three costs is an inline script — genuinely just a <script> tag whose contents run synchronously while the browser is still parsing the HTML document, which means it executes before the first paint and before React ever hydrates anything. It's not a trick so much as using a browser behavior that predates React entirely, in service of a very React-specific problem.
The date-formatting problem, concretely
A UTC timestamp like 2026-06-15T18:00:00Z is a fixed instant, but how a human reads it depends entirely on their locale and time zone — and toLocaleDateString() on the server reflects the server's locale, not the visiting user's.
Here's the naive Client Component approach, and exactly why it fails:
"use client";
export function EventDate({ date }: { date: string }) {
return <p>{new Date(date).toLocaleDateString()}</p>;
}
During SSR, this runs in Node.js and formats using the server's locale — say 6/15/2026. On hydration, React re-executes the same component in the browser, which formats using the browser's locale — say 2026/6/15 for a Japanese locale. React notices these don't match, throws a hydration error, and recovers by discarding the server output and re-rendering client-side from the nearest boundary — which is the visible flash.
This is genuinely easy to miss in local development specifically because your dev machine's locale usually matches your own browser's locale, so nothing ever looks wrong until a real user with a different locale hits it in production. The fix for catching it early is deliberately mismatching them yourself: run your dev server with different TZ/LANG values than your browser (TZ=UTC LANG=ja_JP.UTF-8 next dev is a reasonable stress test — TZ=UTC doubles as a sensible default since most production servers run in UTC anyway).
The inline-script fix
The pattern has three parts: the server renders its best-guess value into an element with a stable ID, an inline <script> immediately after it corrects that element's content using the browser's actual locale, and suppressHydrationWarning on the element tells React to trust whatever's now in the DOM rather than fighting it.
// app/events/page.tsx
import { getEvent } from "@/app/lib/events";
export default async function Page() {
const event = await getEvent("nextjs-conf");
return (
<section>
<h1>{event.name}</h1>
<p id="event-date" suppressHydrationWarning>
{new Date(event.date).toLocaleDateString()}
</p>
<script
dangerouslySetInnerHTML={{
__html: `document.getElementById("event-date").textContent=new Date("${event.date}").toLocaleDateString()`,
}}
/>
</section>
);
}
The order here matters: because the script tag runs synchronously during HTML parsing, it corrects #event-date's text before the browser paints anything — there's no flash between wrong-value-visible and right-value-visible, because the wrong value is never actually painted to the screen at all.
What suppressHydrationWarning is actually doing
This is worth understanding precisely rather than treating as a magic incantation to silence a warning. Without it, a text mismatch during hydration makes React treat it as a genuine hydration error — it recovers by client-rendering from the nearest error or Suspense boundary, which causes the exact flash you're trying to avoid, and critically, any other inline-script corrections inside that same boundary are lost, because React rebuilds that DOM subtree from scratch and the scripts that already ran don't re-run.
With suppressHydrationWarning present, React does something different: it keeps whatever's already in the DOM and simply discards its own client-rendered output for that specific element. The DOM wins, not React's expectation. The inline script has already put the correct value there before hydration even starts, so telling React to defer to the DOM is exactly the right instruction — even for Server Components, since React still diffs the DOM against the RSC payload during hydration, and the script has already changed what's in the DOM by that point.
Making it work for client-side navigation too
The inline-script approach as written only fires on a genuine hard navigation — a full page load or refresh — because that's the only time HTML is actually being freshly parsed. A <Link>-triggered soft navigation renders the component from the RSC payload directly, and any DOM-inserted <script> simply doesn't execute in that path; scripts inserted via DOM updates never run in the browser, full stop.
The fix is making the component a genuine Client Component, so toLocaleDateString() runs directly in the browser for soft navigations, while the inline script continues to handle hard navigations:
// app/components/inline-script.tsx
export function InlineScript({ html }: { html: string }) {
return (
<script
type={typeof window === "undefined" ? "text/javascript" : "text/plain"}
suppressHydrationWarning
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
// app/components/local-date.tsx
"use client";
import { useId } from "react";
import { InlineScript } from "./inline-script";
export function LocalDate({
date,
options,
}: {
date: string;
options?: Intl.DateTimeFormatOptions;
}) {
const id = useId();
return (
<>
<time id={id} dateTime={date} suppressHydrationWarning>
{new Date(date).toLocaleDateString(undefined, options)}
</time>
<InlineScript
html={`{var n=document.getElementById("${id}");if(n)n.textContent=new Date("${date}").toLocaleDateString(undefined,${JSON.stringify(options)})}`}
/>
</>
);
}
The type="text/javascript" vs type="text/plain" toggle inside InlineScript is solving a different, smaller problem: React logs a development warning when rendering produces <script> tags directly, so flipping the type to something inert (text/plain) on the client side avoids that noise, while suppressHydrationWarning covers the resulting type-attribute mismatch between server and client render. useId generates a stable, unique identifier per component instance so multiple LocalDate components on one page don't collide over the same DOM ID.
Two small but real details worth carrying forward: use a semantic <time> element with a dateTime attribute holding the raw ISO string, so search engines and screen readers can parse the actual date regardless of what locale-formatted text happens to be displayed. And if your app runs a strict Content Security Policy without 'unsafe-inline' allowed, these inline scripts are blocked outright by that policy — you'll need a nonce, covered in the dedicated Content Security Policy guide, to permit them.
The same pattern applied to themes
A page server-renders with some default theme (light, typically), but the user may have a saved preference in localStorage. The identical inline-script technique applies — read the value, set a data-theme attribute on <html>, before paint:
// app/layout.tsx
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="en" data-theme="light" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `(function(){try{var t=localStorage.getItem("theme");if(t)document.documentElement.setAttribute("data-theme",t)}catch(e){}})()`,
}}
/>
</head>
<body>{children}</body>
</html>
);
}
[data-theme="light"] {
--background: #ffffff;
--foreground: #000000;
}
[data-theme="dark"] {
--background: #0a0a0a;
--foreground: #ededed;
}
Placing the script in <head> means the correct theme attribute is set before anything in <body> gets painted at all. The try/catch wrapper isn't defensive paranoia — localStorage genuinely can throw in some browser privacy modes, and letting that exception propagate would mean a broken script tag taking down more than just the theme logic.
If you'd rather read the theme on the server: use a cookie, carefully
Unlike localStorage, a cookie travels with every request, so the server genuinely can read it via cookies(). But there's a real cost to doing that in the root layout specifically: it opts your entire app out of static prerendering, and under Cache Components, it forces every segment beneath that layout into blocking rendering. If keeping the page statically prerendered matters to you — and it usually should — read the theme cookie inside the inline script instead of via cookies() server-side, giving up nothing:
<script
dangerouslySetInnerHTML={{
__html: `(function(){try{var m=document.cookie.match(/(?:^|; )theme=([^;]*)/);if(m)document.documentElement.setAttribute("data-theme",decodeURIComponent(m[1]))}catch(e){}})()`,
}}
/>
Syncing with React state you control directly
When a Client Component owns interactive state — which accordion panel is expanded, say — the initial useState value needs to already agree with whatever the inline script set in the DOM, or you're right back to a mismatch. The fix is a lazy state initializer reading from the exact same source the script reads from:
const [openId, setOpenId] = useState(() => {
if (typeof window === "undefined") return DEFAULT_ID;
return localStorage.getItem(STORAGE_KEY) ?? DEFAULT_ID;
});
Both the inline script (setting open attributes on <details> elements before paint) and this lazy initializer read localStorage independently, but because they read the same key, they always agree — React's initial render state and the DOM the script already produced are never in conflict.
A development-only wrinkle: Strict Mode remounts
This is worth knowing about specifically so it doesn't look like a real bug when you hit it: in development, React's Strict Mode remounts components once to help surface bugs, and on that remount it resets <html>, <head>, and <body> back down to only the attributes JSX itself manages — which clears whatever the inline script set, since the script isn't something React tracks as "its" attribute.
The practical fix is re-applying the value in a useLayoutEffect, inside whatever component actually owns the theme toggle logic — this runs before paint, so it's a no-op in production (where the script already got it right and nothing changed), and only actually does anything during the dev-mode remount:
"use client";
import { useLayoutEffect } from "react";
export function ThemeToggle() {
useLayoutEffect(() => {
const theme = localStorage.getItem("theme");
if (theme) document.documentElement.setAttribute("data-theme", theme);
}, []);
function toggle() {
const next =
(localStorage.getItem("theme") ?? "light") === "dark" ? "light" : "dark";
localStorage.setItem("theme", next);
document.documentElement.setAttribute("data-theme", next);
}
return <button onClick={toggle}>Toggle theme</button>;
}
When a different approach is actually the right call
The inline-script technique is specifically for client-only state the server genuinely cannot know. It's not the right tool for everything:
| Situation | Better approach |
|---|---|
Date depends on request data you do have server-side (cookies, Accept-Language header) | Format server-side directly with headers()/cookies() |
| Date updates live — a countdown, a running clock | A Client Component with useEffect and suppressHydrationWarning — the value is inherently always changing, so a one-time script correction doesn't fit |
| Page is already fully dynamic anyway | Just format using Accept-Language server-side; there's no static-rendering benefit left to protect |
| Translating actual content, not just formatting | Real internationalization — per-locale static builds or dynamic rendering, not this technique |
Why not just useEffect?
useEffect runs after both hydration and paint — so the user genuinely sees the server's (wrong) value first, then watches it change, which is the exact flash this whole article is about avoiding. It also triggers a re-render on the state update, which can needlessly reactivate parent Suspense boundaries. useLayoutEffect is earlier — before paint — but still after hydration, meaning it prevents the hydration-to-paint flash but not the flash between raw HTML arriving and React hydrating at all, which matters more than it sounds like on a slow connection where the gap between "HTML painted" and "React hydrated" can be substantial. The inline script is earlier still: it runs during HTML parsing, before React is involved in any capacity.
Why not read Accept-Language or cookies server-side instead?
You can, and for request-scoped data specifically, it's often the more correct answer entirely. But it comes with real trade-offs worth weighing deliberately: reading headers() for Accept-Language at request time either opts a route into dynamic rendering or, under Cache Components, requires wrapping the affected content in <Suspense> (meaning a fallback flashes anyway, just a different kind of flash than the one you were avoiding) — and Accept-Language alone doesn't carry time zone information at all, only locale.
Key Takeaways
| Technique | When to use it |
|---|---|
Inline <script> + suppressHydrationWarning | Client-only state (theme, saved UI state, locale-formatted dates) that the server can't know but doesn't need to be live |
Client Component + useEffect | Genuinely live values — a running clock, a countdown |
Server-side headers()/cookies() | Request data the server can legitimately read, when opting into dynamic rendering (or a Suspense fallback) is acceptable |
Lazy useState initializer | Keeping React's own state in sync with whatever the inline script already set in the DOM |
useLayoutEffect re-application | Working around React Strict Mode clearing <html>/<body> attributes in development only |
The inline-script pattern feels like a small trick the first time you see it, but it's solving a problem no amount of clever useEffect placement actually can: getting a corrected value into the DOM before the browser paints anything at all, which is a window of time React itself has no access to, because React hasn't started running yet. Once you've internalized that timing — parse, then paint, then hydrate — the rest of this pattern (suppressHydrationWarning, the lazy state initializer, the Strict Mode workaround) is just consequences of taking that timing seriously.


