This post was created with AI assistance and reviewed for accuracy before publishing.

If your production console shows Minified React error #418, React is telling you that the HTML the server sent and the first render in the browser did not match. The development build spells it out ("Hydration failed because the server rendered HTML didn't match the client") and shows a diff. The production build gives you a number and a link to react.dev/errors/418.

It is easy to treat it as console noise, because the page still works. It is not free. When hydration fails, React throws away the server-rendered tree and renders the whole root again in the browser. On my site that one error took mobile Total Blocking Time from 0 to 420 ms and the Lighthouse performance score from 96 to 83, and pushed Largest Contentful Paint 1.2 seconds past First Contentful Paint, which had always been equal.

I have shipped this error five different ways on one Next.js site. Here is each one, how it was found, and the fix that stuck.

1. Randomness or time in a useState initializer

The games on the site generate a puzzle, a word or a board. The obvious code seeds state when the component is created:

// Wrong: the initializer runs on the server and again in the browser. const [puzzle, setPuzzle] = useState(() => generatePuzzle()); 

The initializer runs twice, once during prerendering and once during hydration, and Math.random() gives a different answer each time. The same thing happened in a lorem ipsum generator, a mock data generator and a password generator. Anything that reads Date.now() or new Date() while rendering has the same problem.

The fix is a deterministic first value, with the random one set after mount:

const [puzzle, setPuzzle] = useState(INITIAL_PUZZLE); // fixed, the same everywhere useEffect(() => { setPuzzle(generatePuzzle()); // runs only in the browser, after hydration }, []); 

The first frame shows the fixed puzzle for an instant. For a game that is fine. For anything where the flash matters, render a placeholder of the same size until the effect runs.

2. Formatting with the machine's locale

// Wrong <span>{downloads.toLocaleString()}</span> <time>{new Date(post.date).toLocaleDateString()}</time> 

With no locale argument, toLocaleString() uses whatever the current machine prefers. The server that prerenders the page (on Vercel, en-US in UTC) and a visitor in Germany or India produce different strings, so the text differs and hydration fails. It never reproduces on your own machine if your machine matches the server.

Pass the locale you rendered with, or move the formatting after mount:

<span>{downloads.toLocaleString('en-US')}</span> 

To catch this class of bug, load the deployed page in a browser with a different locale and time zone. In Playwright that is two options on the context:

const context = await browser.newContext({ locale: 'de-DE', timezoneId: 'America/Los_Angeles' }); 

3. Browser-only APIs while rendering

Five tools on the site called something during render that only exists, or only behaves the same, in a browser: DOMParser in an XPath evaluator, document in an SVG animation tool and an HTML-to-Pug converter, and URL.createObjectURL in an SVG viewer. That last one is a good example of why this can hide: on the server it produced nothing useful, in the browser it produced a blob: URL, and the visible symptom was a broken image, not an error.

// Wrong: runs during render on the server too. const src = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' })); 

The fix is to compute these only after the component has mounted:

function useHasMounted() { const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); return mounted; } const mounted = useHasMounted(); const src = useMemo( () => (mounted ? URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' })) : null), [mounted, svg], ); 

Remember to revoke the URL when nothing can reach it any more, but that is a separate bug.

4. usePathname() in a prerendered component

This is the one that cost the most, because I fixed it and then shipped it again.

The site's navigation highlights the current page. The highlight compared the path:

const pathname = usePathname(); const isActive = pathname === href; 

On the deployed homepage this logged #418 on every visit. It never happened locally, with next dev or with a local production build. The comparison for the / link came out differently in Vercel's prerender of the index route than in the browser, so the active class differed between the server HTML and the first client render.

The first time, I fixed it in one header. Later a different navigation design carried the same comparison into a new component, and it went live again. That second time is the one that took Lighthouse from 96 to 83.

The fix that stuck is a shared hook that gates only the / comparison on mount and leaves every other link server-rendered, so the other routes keep their active state from the first paint. Simplified, it looks like this:

export function useIsRouteActive(href: string) { const pathname = usePathname(); const mounted = useHasMounted(); if (href === '/') return mounted && pathname === '/'; return pathname === href || pathname.startsWith(`${href}/`); } 

The second part of the fix was making sure it could not come back. The repository has a small script that runs before every build and fails when a client component compares usePathname() directly. A lint rule would do the same job. Writing the lesson down had not been enough.

5. A script that edits <body> before React gets there

The site sets a colour scheme and, on festival days, a few data- attributes on <body> from an inline script, so the right look paints before any JavaScript bundle loads. React then hydrates <body>, finds attributes it did not render, and complains.

This is the one case where suppressHydrationWarning is the right tool:

<body suppressHydrationWarning> 

It only silences attribute and text differences on that one element, not its children, which is exactly the scope you want. Two rules keep it honest: only the element the script edits gets it (here <body>, not <html>), and the script only ever sets attributes, never content.

suppressHydrationWarning is not a fix for the first four causes. Those are real differences in what renders, and hiding the warning leaves the re-render in place.

How to find yours

  • Read the console on the deployed site, not just locally. Three of these five never reproduced on my machine.
  • Run the development build once to get the readable message and the diff React prints, which names the element and the two values.
  • Load the page with a foreign locale and time zone. It finds cause 2 and anything else that reads the environment.
  • Search the codebase for Math.random, Date.now, new Date(, toLocale, window., document., DOMParser and usePathname inside components that render on the server.

The common thread is simple: everything a component renders the first time must be computable on the server, from the same inputs, with the same answer. Anything that depends on the browser, the clock, chance or the visitor waits until after mount.