Skip to content

Next.js error

Text Content Does Not Match Server-Rendered HTML

The error

Warning: Text content did not match. Server: "2 hours ago" Client: "3 hours ago"

The narrower cousin of a hydration mismatch, and it is nearly always one of three things: a relative time, a locale-formatted number, or whitespace.

This is a hydration mismatch confined to a text node, which makes it easier to diagnose than the general case: React prints both values, so you can usually see the cause in the message itself.

Three causes account for almost all of them.

1. Relative time

"2 hours ago", "just now", "in 3 days". The server computes it at render time; the browser computes it again at hydration time. If a boundary fell between the two - a minute, an hour, midnight - the strings differ.

Render the absolute value on both passes and upgrade after mount:

'use client';
 
export function RelativeTime({ iso }: { iso: string }) {
  const [relative, setRelative] = useState<string | null>(null);
  useEffect(() => setRelative(formatRelative(iso)), [iso]);
 
  return (
    <time dateTime={iso} title={new Date(iso).toISOString()}>
      {relative ?? formatAbsolute(iso)}
    </time>
  );
}

The HTML contains a real date, which is also what a crawler and a screen reader should receive. The relative form is a convenience that arrives a moment later.

2. Locale-dependent formatting

toLocaleString(), toLocaleDateString(), Intl.NumberFormat with no explicit locale. The server takes its locale and timezone from the Node process - typically en-US and UTC. The browser takes the user's. 1.234,56 on one side and 1,234.56 on the other.

Always pass the locale and the timezone explicitly:

new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(n);
new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin' }).format(d);

If the locale genuinely depends on the visitor, it has to reach the server - from the URL segment or a cookie - so both renders agree. That is what a locale-aware routing setup is for, and it is the reason the locale belongs in the route rather than in client state.

3. Whitespace around an expression

The subtlest one, and it looks like a formatter's fault:

<span>
  {count} items
</span>

JSX collapses the newline and indentation into a single space here, which is usually what you want and occasionally not - particularly around punctuation or inside white-space: pre. If the message shows two strings differing only by a space, this is it. Write the expression on one line, or make the space explicit with {' '}.

What looks like a fix and is not

suppressHydrationWarning on the parent. It applies one level deep, so on a wrapper it silences nothing useful, and where it does apply it hides the warning without preventing the re-render. On a single <time> element whose difference you have decided is correct, it is legitimate. As a way to make a list of mismatches quiet, it is not.

Rendering the whole component client-side. The mismatch goes away because the server render does. For a timestamp inside otherwise static content, that is a large trade for a small label.

Whether it is worth chasing

One mismatched label costs almost nothing. The same component repeated across a list means React discards and re-renders the list, and a mismatch high in the tree can take most of the page with it - which shows up as a Largest Contentful Paint that no amount of image work improves.

The console counts them. If there are more than a handful on a page, that is a measurement, not a warning.

Related questions

It says warning. Can I ignore it?
The console calls it a warning; React still discards and re-renders the subtree containing it. On a small label the cost is negligible. On a list of hundreds of timestamps it is the whole list.
Why does it appear on some page loads and not others?
Because the difference is often time-based. A page rendered at 11:59:59 and hydrated at 12:00:01 crosses a boundary that a page loaded mid-minute does not.