Next.js Hydration Mismatch: The SVG Gradient Trap
If you've ever stared at a "next.js hydration mismatch" error with no obvious cause, an SVG gradient's id attribute might be the culprit. A single Math.random() tucked inside a template literal can derail your entire server-render.

If you've ever stared at a next.js hydration mismatch error with no obvious explanation, the culprit might be hiding inside an SVG gradient id. Our own studio hit this exact bug in Wordmark.tsx and BlobMark.tsx — a component that looked purely presentational, yet threw the infamous "Hydration failed because the initial UI does not match what was rendered on the server." The fix was a single import.
What a Next.js Hydration Mismatch Really Means – And Where It Can Hide
A hydration mismatch occurs when the initial client‑side render doesn’t produce the exact same HTML that the server streamed. React then can’t attach its event handlers cleanly and throws an error. The official Next.js docs explain it as "Text content does not match server‑rendered HTML," but the mismatch doesn’t have to be visible text – any attribute, key, or even an invisible id that differs will trigger it.
The classic suspects are easy to spot: rendering <time>{new Date().toISOString()}</time> or conditionally reading window.innerWidth during render. What’s far more insidious is the SVG gradient trap — a mismatch caused by a Math.random() call that lives entirely inside an attribute.
The SVG Gradient ID Trap: How Math.random() Broke Our Wordmark
We hit this in our web development studio’s Next.js project, inside a shared SVG component used for brand wordmarks and decorative blobs.
// ❌ Buggy: Math.random() produces a different id on server and client
const gradientId = `gradient-${Math.random()}`;
return (
<svg>
<defs>
<linearGradient id={gradientId}>
<stop offset="0%" stopColor="var(--color-brand)" />
<stop offset="100%" stopColor="var(--color-accent)" stopOpacity={0} />
</linearGradient>
</defs>
<text fill={`url(#${gradientId})`}>Techpotions</text>
</svg>
);The component looked harmless — no browser API, no locale, no date. And yet, every server render produced a different id than the client’s subsequent render. React pointed at the <svg> nesting, but the real mismatch was the id attribute itself.
The One-Line Fix: useId() Makes Ids Stable Across Server and Client
React ships useId() precisely for this scenario. It generates an identifier that is:
- Stable across server and client for the same component instance.
- Unique across instances, preventing duplicate ids in the DOM.
Replacing Math.random() with useId() eliminated the hydration mismatch entirely.
// ✅ Fixed: useId() from React is stable across server and client
import { useId } from 'react';
const gradientId = useId();
return (
<svg>
<defs>
<linearGradient id={gradientId}>
{/* ... stops ... */}
</linearGradient>
</defs>
<text fill={`url(#${gradientId})`}>Techpotions</text>
</svg>
);No suppressHydrationWarning, no useEffect gymnastics. Just one hook. We’ve since made this a standing rule in our project instructions: no Math.random() in an SVG linearGradient id, use useId().
The General Rule: Any Impure Render Value Is a Hydration Time Bomb
This bug teaches a larger lesson worth internalising: a hydration mismatch is any render-time value that is not a pure function of props and state. Critically, these values can hide in attributes you wouldn’t think to audit:
Impure Source | Example | Why It Triggers a Mismatch |
|---|---|---|
| | Server and client get different random numbers |
| | Timestamp differs between renders |
| | Only available on the client |
| | |
| | Server has no navigator API |
Request‑time data | | User‑specific data without proper serialisation |
As the community on Stack Overflow affirms, even random ids inside SVGs can cause the “initial UI does not match” error. The error message pointing at surrounding markup is often a red herring — the real source lives in an attribute value you’d almost never inspect.
Debugging Strategy: Grep for Math.random, Date, and Window Before Bisecting
When the error names a component that looks pure, don’t start bisecting your component tree yet. Instead, run:
grep -r 'Math.random' --include="*.tsx" src/components/marks/
grep -r 'Date(' --include="*.tsx" src/
grep -r 'window.' --include="*.tsx" src/In our case, the offending call was a single word inside a template literal in an id attribute — easy to miss in manual review, but instantly caught by a simple grep. If you’re in a monorepo or using complex third‑party libraries, this search can save hours of frustration.
How We Prevent Hydration Mismatches at techpotions
After shipping the useId() fix, we added a lint rule that flags any Math.random() inside JSX‑returning functions. It’s one of several defensive practices we bake into our Next.js projects. For example, when we built a smooth‑scroll animation that needed to work on Windows, we fixed a Lenis scroll issue by ensuring the client‑only logic never leaked into the server render.
If your app is plagued by hydration errors you can’t trace, our web development team offers code‑reviews and focused debugging sessions. We focus on the attribute‑level detail that generic tooling often misses.
Ready to start? Let’s make your Next.js app bulletproof.
FAQ
Why does Math.random() cause a hydration mismatch?
Because server and client rendering are independent processes. Math.random() returns a different number each time, so the generated gradient id is different on the server than on the client. React then detects that the initial HTML doesn’t match what the client expects and throws the mismatch error.
How does useId() solve the problem?
React’s useId() generates a stable, deterministic identifier that is guaranteed to be identical on the server and client for the same component instance. It doesn’t rely on impure data, so the id matches every time.
Can other attributes aside from id trigger a hydration mismatch?
Absolutely. Any attribute — class names, keys, data-* attributes, inline styles, or even render‑time logic that affects markup structure — can trigger a mismatch if it derives from impure sources like Date.now(), localStorage, window, or request‑time data. Treat every attribute as a potential leak point.