Skip to main content
All posts
Next.js6 min read

Next.js Hydration Mismatch: Random UUIDs Render Different HTML on Server and Client

Generating field identifiers with a random UUID looked harmless until React started warning about mismatched markup on every page load. The server and the browser were each producing a perfectly valid id, just never the same one.

Portrait of Benjamin Fazli, the author of bfzli.com

Benjamin Fazli

Principal EngineerSkopje, North Macedonia

Abstract purple light streaks in motion, suggesting duplicated renders

The symptom

A form component generated its own field identifiers so that every label could point at the right input. It worked in isolation, it passed review, and then production began logging hydration errors on every page that rendered the form. The page painted, flickered, and settled with the focus ring attached to the wrong field.

The offending line was almost too small to notice:

jsx
const fieldId = crypto.randomUUID()

Why it breaks

The component renders twice: once on the server to produce HTML, and once in the browser to attach behaviour. React expects both passes to produce identical markup, then reuses the server HTML and simply wires up the events.

crypto.randomUUID() cannot satisfy that contract. The server generates one value, the browser generates another, and React finds a for attribute that no longer matches any id. It cannot reconcile the difference, so it throws away the server markup for that subtree and re-renders it on the client. That is the flicker, and it is also why the label stopped working.

The same trap applies to anything that is not deterministic across environments:

  • Math.random() and Date.now()
  • new Date().toLocaleString(), which depends on locale and time zone
  • reading window.innerWidth during render
  • anything pulled out of local storage before the component has mounted

The fix

React already ships a hook for exactly this problem. useId produces a stable identifier that is generated once and reused on both passes:

jsx
import { useId } from 'react'

const ContactField = () => {
    const id = useId()

    return (
        <div>
            <label htmlFor={id}>Work email</label>
            <input id={id} type='email' name='email' />
        </div>
    )
}

For values that genuinely have to come from the browser, keep the first render deterministic and read the real value after mount:

jsx
const [renderedAt, setRenderedAt] = useState(null)

useEffect(() => {
    setRenderedAt(new Date().toLocaleString())
}, [])

The first pass renders nothing, the second fills it in, and both environments agree on the markup that actually gets compared.

If a value must differ between server and client and the difference is genuinely cosmetic, suppressHydrationWarning on that single element is acceptable. Putting it on a wrapper to silence a category of warnings is not, and it will hide the next real bug.

The wider lesson

Hydration errors are rarely about the component you are looking at. They are about a value that is not deterministic somewhere inside it. Search the subtree for randomness, dates, and browser globals before you start rewriting the layout.