</>CodeWithKarani

Decoding minified React error #418, #423 and #425 in Next.js production

Karani GeoffreyKarani Geoffrey10 min read

Your error tracker is full of a message that tells you nothing. Four numbers, no stack trace worth reading, no component name, and a link you have probably never clicked. Meanwhile next dev is silent, the page looks correct in the browser, and the only difference is that this build went through next build.

React strips its error messages in production builds on purpose. Every invariant string is replaced at build time with a number, because shipping several hundred sentences of English to every visitor costs real bytes. The number is not a hash and it is not random: it is an index into a lookup table that ships with React, and you can decode it in about five seconds once you know the URL.

The second thing worth knowing is that these errors are not caused by Vercel, or by production, or by minification. They are caused by hydration mismatches that next dev is more forgiving about, and you can reproduce every one of them on your own laptop without deploying anything.

decode the number at https://react.dev/errors/<code>. If the console message includes ?args[]= query parameters, keep them: React puts the substituted values there, and that is usually the offending element.

  • 418 Hydration failed because the server rendered content did not match the client.
  • 423 There was an error while hydrating but React recovered by client rendering the entire root.
  • 425 Text content does not match server-rendered HTML.
  • 329 Unknown root exit status.

Reproduce it locally with next build && next start, not next dev. That is the whole "only happens in production" mystery.

The exact error

The full string React emits is fixed, and knowing its shape helps you search your logs for it:

Uncaught Error: Minified React error #418; visit https://react.dev/errors/418 for the full
message or use the non-minified dev environment for full errors and additional helpful warnings.

When the underlying message had placeholders, React appends the values it would have substituted:

Minified React error #418; visit https://react.dev/errors/418?args[]=div for the full message ...

That ?args[]=div is the single most useful piece of information in the whole message and most people truncate it out of their bug report. It tells you which element React was hydrating when the mismatch was detected. Open the URL with the query string intact and react.dev renders the message with the value filled in.

What the numbers actually mean

Be careful here, because the internet gets this wrong constantly. I have read multiple blog posts and issue comments that confidently assign the wrong message to 418 and 425, including summaries generated from the issue threads themselves. The authoritative source is React's own codes.json, which is what react.dev renders. Here is what those four are, quoted from it:

CodeMessageWhat it means for you
418Hydration failed because the server rendered X didn't match the client. As a result this tree will be regenerated on the client.A structural mismatch. The args[] value names the element.
422There was an error while hydrating but React was able to recover by instead client rendering from the nearest Suspense boundary.Damage contained to one boundary. This is the good outcome.
423There was an error while hydrating but React was able to recover by instead client rendering the entire root.No Suspense boundary existed above the problem, so the whole page re-rendered on the client.
425Text content does not match server-rendered HTML.A text node differs. Whitespace counts as text.
329Unknown root exit status.An internal state React did not expect, usually a downstream effect of the above.

The 422 versus 423 distinction is the actionable one and almost nobody writes about it. Both mean "hydration broke and React recovered". The difference is blast radius. 423 means every piece of interactive state on the page was thrown away and rebuilt, which users experience as a flash and a scroll jump. 422 means only the subtree inside one <Suspense> boundary was rebuilt. If you are seeing 423, part of your fix is to add a boundary, independent of fixing the mismatch itself.

Why it only happens in production

It does not. It happens in every production build, including the one you can run on your own machine. What is true is that next dev behaves differently in two ways.

First, the development build of React ships the full message text, so instead of a number you get a paragraph and, in recent versions, a diff showing the server output against the client output. That is not a different bug, it is the same bug with better reporting.

Second, the dev server re-renders more eagerly and applies different whitespace handling in the compiled output, so certain subtle mismatches genuinely do not fire. That is the class of bug that convinces people it is a Vercel problem.

npx next build && npx next start

Open http://localhost:3000 and check the browser console. If the numeric errors appear, you have removed the deployment platform from the investigation entirely and you can iterate in seconds instead of minutes. This is the same instinct as any "works locally, fails on deploy" problem: shrink the difference until only one variable remains, which I go through in more detail for the ERR_REQUIRE_ESM case.

Why whitespace is a structural difference, not a cosmetic one Server HTML, as sent Service  and protection text nodes separated by <!-- --> Client render, in memory Service and protection one text node React walks both trees in lockstep. Different node counts, mismatch at the first difference. Minified React error #425: Text content does not match server-rendered HTML.
Hydration is not a comparison of rendered output. It is React walking the existing DOM and claiming each node in order. One extra text node throws off everything after it.

The bug shape that catches people: arrays of raw strings

A component that returns an array of strings looks harmless:

export default function Benefits() {
  return [
    `Service\n and protection`,
    `Help when\n you need it`,
    `Complimentary\n member benefits`,
  ];
}

React is perfectly happy to render an array of text children. The problem is what that produces in HTML. Adjacent text nodes cannot be represented in HTML on their own, because the parser would merge them into one, so React's server renderer inserts <!-- --> comment markers between them to preserve the boundaries. The literal newlines inside each template string are then part of the serialised HTML.

Anything that touches that HTML between your server and React's hydration pass can shift those boundaries: an HTML minifier that collapses whitespace, a proxy that rewrites the document, a browser extension that reorganises the DOM. React then finds a different number of text nodes than it created, and reports 425 or 418 depending on where the walk broke.

The fix is to stop relying on raw adjacent text nodes:

export default function Benefits() {
  const items = [
    "Service and protection",
    "Help when you need it",
    "Complimentary member benefits",
  ];
  return (
    <>
      {items.map((text) => (
        <p key={text}>{text}</p>
      ))}
    </>
  );
}

Each string now lives inside an element, so there is a stable, unambiguous DOM node for React to claim. If you actually wanted the line break, use an explicit <br /> or a CSS white-space rule rather than an embedded newline, because CSS survives HTML normalisation and a raw newline does not.

The fix, step by step

Step 1: Decode the number and keep the args

Copy the whole URL out of the error, including everything after the question mark, and open it. You now have a sentence instead of a number, and often the name of the element involved.

Step 2: Reproduce with a local production build

rm -rf .next
npx next build
npx next start

Expected result: the same numeric errors in the console. If they do not appear locally but do appear on the deployed site, the difference is in the response, not in your code. Compare the raw HTML:

curl -s http://localhost:3000/the-page > local.html
curl -s https://yoursite.com/the-page > deployed.html
diff <(tr -d ' \n' < local.html) <(tr -d ' \n' < deployed.html) | head

Stripping whitespace before diffing is deliberate. If the files differ only after you stop ignoring whitespace, you have proved that something in the delivery path is rewriting whitespace, which is the whole ball game.

Step 3: Narrow to the component

Production builds give you no component names, so bisect instead. Comment out half the page, rebuild, check. It feels crude and it is faster than anything else available, usually three or four iterations. The common offenders are worth checking first:

  • Anything calling Date.now(), new Date(), Math.random() or crypto.randomUUID() during render.
  • Dates formatted with the user's locale or timezone, which differ between a UTC server and a browser in Nairobi.
  • typeof window !== "undefined" branches inside a component that is server rendered.
  • Reading localStorage, matchMedia or a theme preference during the first render instead of in an effect.
  • Invalid HTML nesting, such as a <div> inside a <p>. The browser silently restructures it during parsing and React does not expect that.

That last one deserves emphasis because it produces a mismatch nobody can see in the source. The HTML parser moves the block element out of the paragraph, so the DOM the browser built is genuinely not the DOM your JSX described.

Step 4: Contain what you cannot fix immediately

<Suspense fallback={<Skeleton />}>
  <TheProblematicWidget />
</Suspense>

This does not fix a mismatch. What it does is turn error 423, where the entire root was thrown away and re-rendered, into error 422, where only this subtree was. That is the difference between the whole page flickering and one widget flickering. It is a legitimate mitigation while you find the real cause, and it is good practice regardless.

For a value that genuinely cannot match, such as a relative timestamp, render the server-safe version first and update in an effect:

const [rendered, setRendered] = useState(false);
useEffect(() => setRendered(true), []);
return <time>{rendered ? formatRelative(date) : formatAbsolute(date)}</time>;

Verification

  1. Local production build, console clean. Run next build && next start, load every route that was reporting errors, and confirm no Minified React error lines appear. Check with a hard reload, since a cached bundle will happily hide the fix.
  2. Confirm the recovery path. If you added a Suspense boundary while still chasing the mismatch, you should now see 422 rather than 423. Seeing the number change is proof the boundary is in the right place.
  3. Check the deployed preview, in an incognito window with extensions disabled. Browser extensions that inject or reorganise DOM nodes cause real hydration errors that are not your bug, and they are a genuine source of noise in error tracking.
  4. Watch your error tracker for 24 hours. Hydration errors are often route-specific or data-specific. Zero errors on the three routes you tested is not the same as zero errors, which is the gap between a build passing and a deployment being trustworthy that I go into in what production grade actually means.

What people get wrong

"Add suppressHydrationWarning." It silences the warning for one element's text content and does not fix the mismatch. The DOM is still wrong, React still re-renders, and you have now hidden the signal. It is legitimate for exactly one thing: a value that is genuinely expected to differ, such as a timestamp rendered server-side, on the specific element that holds it. Sprinkling it upward until the console goes quiet is not a fix.

"Disable SSR for the component." A dynamic import with ssr: false makes the error disappear because the component no longer renders on the server. It also removes that content from the initial HTML, which costs you the thing you were paying for SSR to get. Sometimes correct, for a widget that genuinely depends on browser APIs. Usually it is a retreat dressed as a solution.

"It is a Vercel bug." Occasionally true and usually not. The test is one command: run a production build locally. If it reproduces, it is your code, and you now have a fast feedback loop.

"Just read the stack trace." There is nothing useful in it. Minified production React gives you internal frame names. The number and the args[] values are the entire signal. Enabling productionBrowserSourceMaps in next.config gives you readable frames if you need them, at the cost of shipping source maps.

When it is still broken

  1. Check for HTML transformation in front of your app. A CDN with HTML minification enabled, a proxy that injects analytics, or a translation layer that rewrites text. Any of them can break hydration on an application that is entirely correct. Compare the bytes with the diff in step 2.
  2. Check for two React copies. npm ls react react-dom should show a single resolved version of each. Two copies in a monorepo produce a family of confusing hydration and hooks errors.
  3. Check server versus client timezone. A UTC serverless function and a browser at UTC+3 will format the same date differently. This produces mismatches only for users in certain regions, so it looks intermittent and it is not.
  4. Look at cached HTML. If a page is statically generated and then the data changes, a stale cached document hydrating against fresh client state produces the same errors. Confirm your revalidation actually runs.

The habit to take from this: when a system gives you a number instead of a message, find the lookup table before you start guessing. React documents its at react.dev/errors, and the whole exercise takes less time than reading one wrong Stack Overflow answer about it.

Frequently asked questions

How do I decode a minified React error number?
Open https://react.dev/errors/ followed by the number, for example https://react.dev/errors/418. If the console message contains query parameters like ?args[]=div, keep them on the URL, because React puts the values it would have substituted into the message there and react.dev fills them in. Those substituted values usually name the element React was hydrating when the mismatch was found.
What is the difference between React error 422 and 423?
Both mean hydration failed and React recovered by falling back to client rendering, but the blast radius differs. 423 means React re-rendered the entire root, so all client state on the page was discarded. 422 means it recovered from the nearest Suspense boundary, so only that subtree was rebuilt. If you see 423, adding a Suspense boundary around the problematic area limits the damage even before you fix the underlying mismatch.
Why do minified React errors only appear on my deployed site and not in development?
Because next dev uses the development build of React, which ships full error messages instead of numbers and handles some whitespace differences more forgivingly. The bug is not caused by deployment. Run next build followed by next start on your own machine and the same numeric errors almost always appear, which removes the hosting platform from the investigation and gives you a fast feedback loop.
Does suppressHydrationWarning fix a hydration mismatch?
No. It suppresses the warning for one element's text content while leaving the mismatch in place, so React still discards and re-renders that content on the client. It is appropriate only for a value that is genuinely expected to differ between server and browser, such as a timestamp, applied to the specific element holding that value. Using it to silence a console is hiding the signal rather than fixing the cause.
#React#Next.js#Hydration#SSR#Debugging#Vercel
Keep reading

Related articles