</>CodeWithKarani

suppressHydrationWarning Isn't Cosmetic: It Freezes the Server Value

Karani GeoffreyKarani Geoffrey7 min read

A client in Nairobi messages you: every timestamp on the dashboard is three hours behind. You check the code and it looks right, it calls toLocaleString(), the browser is on Africa/Nairobi, the API returns proper ISO strings. Then you notice the thing you added weeks ago to make a noisy console warning go away: suppressHydrationWarning.

That attribute is not a warning suppressor. It is an instruction to React to stop caring about that node's content, and "stop caring" includes not fixing it. The React documentation says so, in one sentence, which almost nobody reads: React will not attempt to patch mismatched text content.

So the server-rendered UTC timestamp stays in the DOM. Forever. Your client code runs, computes the correct local time, and React throws the result away because you told it to.

suppressHydrationWarning does not mean "hydrate anyway, quietly". It means React will leave the server-rendered text in place and not replace it with the client value. If the client value only differs during the first render and nothing re-renders that component afterwards, the server value is permanent. The fix is not the attribute: make the first client render byte-identical to the server render (pin the locale and timezone), then update the value in useEffect, which triggers a real re-render that does update the DOM.

The warning people are trying to silence

On React 18 you get some variant of:

Warning: Text content did not match. Server: "01/01/2020, 09:00" Client: "01/01/2020, 12:00"
Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.

On React 19 the message is longer and shows a diff, but it starts the same way:

Hydration failed because the server rendered HTML didn't match the client.

The instinct is to make it stop, and every search result offers the same one-line cure. Here is the broken component that ships as a result:

// Do not do this
export default function PostDate({ iso }) {
  return (
    <time suppressHydrationWarning dateTime={iso}>
      {new Date(iso).toLocaleString()}
    </time>
  );
}

The warning disappears. So does the localisation.

Why the value freezes: hydration is not a render

When React hydrates, it walks the existing server HTML and adopts it rather than creating new nodes. It is deliberately not a diff. In the default case, when it finds text that does not match what the client render produced, it patches the text node and logs the warning you saw. That patch is the only reason the "just live with the warning" approach appears to work.

suppressHydrationWarning removes the patch along with the warning. And it only applies one level deep, to that element's own attributes and direct text content, which is why putting it on a wrapper <div> to cover a deeply nested timestamp both fails to suppress the right warning and can still block the update on the element it is actually attached to.

Same mismatch, two outcomes default Server HTML 09:00 UTC Client render 12:00 EAT React patches the text, warns DOM shows 12:00 EAT suppress Hydration Warning Server HTML 09:00 UTC Client render 12:00 EAT No patch, no warning DOM keeps 09:00 UTC
The client render still happens. Your console.log still fires. The result is simply discarded, which is why this looks like a Next.js bug rather than an attribute doing its job.

The second half of the trap is that nothing ever re-renders the component. If the localised string is computed during render from new Date() and there is no state, no props change and no effect, React has no reason to touch that node again for the life of the page. Hydration was the only chance, and you opted out of it.

This is exactly the case reported in an open Next.js issue where the author observed hydration code running (the logs prove it) while the DOM stubbornly kept the server timezone. It is not a Next.js bug. It is documented React behaviour that the usual advice makes very easy to walk into.

The fix: make the first render deterministic, then update in an effect

Stop trying to hide the mismatch and remove it instead. The server and the first client render must produce the same string; anything user-specific happens after mount.

Step 1: Pin both the locale and the timezone for the initial render

'use client';

import { useEffect, useState } from 'react';

const SERVER_FORMAT = new Intl.DateTimeFormat('en-GB', {
  dateStyle: 'medium',
  timeStyle: 'short',
  timeZone: 'UTC',
});

export default function PostDate({ iso }) {
  const [text, setText] = useState(() => SERVER_FORMAT.format(new Date(iso)));

  useEffect(() => {
    setText(
      new Intl.DateTimeFormat(undefined, {
        dateStyle: 'medium',
        timeStyle: 'short',
      }).format(new Date(iso))
    );
  }, [iso]);

  return <time dateTime={iso}>{text}</time>;
}

Both arguments matter. Pinning timeZone alone still mismatches if Node's ICU data resolves a different default locale than the browser, which is a fun one to debug on a minimal Alpine container. Pin the locale string too and the first render is identical on both sides.

There is no suppressHydrationWarning in that component, and there should not be. Hydration succeeds cleanly, then the effect fires, setState triggers an ordinary re-render, and an ordinary re-render does update the DOM.

Step 2: If you need this in several places, use a hydration flag

'use client';

import { useSyncExternalStore } from 'react';

const noop = () => () => {};

export function useIsHydrated() {
  return useSyncExternalStore(noop, () => true, () => false);
}

useSyncExternalStore takes a separate server snapshot, so this returns false during SSR and during the first client render, then true. It is a cleaner primitive than a useState(false) plus useEffect pair because React knows about the server/client split explicitly.

Step 3: Or move the decision to the server entirely

The best fix for a lot of apps is to not render a local time on the server at all. Store the user's timezone with their profile, format on the server with that explicit zone, and the client never disagrees. It costs one column and removes the whole class of bug. If the value is genuinely per-device (screen width, a stored preference), it belongs behind a mount check, the same way anything user-specific should be, which is also the argument for keeping session state out of places client JavaScript can casually read, as I go through in why localStorage is the wrong home for a JWT.

1. Server render and first client render Both format with locale 'en-GB' and timeZone 'UTC'. Identical strings. 2. Hydration Nothing to reconcile. No warning, no suppression attribute needed. 3. useEffect fires, setState runs A normal re-render, not hydration. React updates the DOM to the local time.
The distinction that fixes this: hydration adopts existing DOM, a re-render owns it. You want the client value delivered by step 3, not smuggled through step 2.

Verification

Three checks, in order, and do them in a production build (next build && next start) because dev-mode hydration behaves differently.

  1. View source, then inspect. The raw HTML should contain the UTC string; the DOM in devtools should contain the local one. If both show UTC, the effect never updated anything.
  2. Change your machine timezone and hard reload. On Linux, timedatectl set-timezone America/Sao_Paulo, restart the browser, reload. The rendered value must follow. This is the test that catches the frozen case, because on a UTC server in a UTC CI runner everything looks fine.
  3. Console must be clean. Zero hydration warnings without any suppressHydrationWarning in the component. If you still need the attribute, the render is still non-deterministic and you have not finished.

What people get wrong

"It only hides the warning." This is the belief the whole article exists to kill. It also stops React repairing the content, and the React docs state it in plain language on the hydrateRoot page.

Putting it on a parent to cover everything inside. It works one level deep. A <div suppressHydrationWarning> wrapping a card full of components does not silence mismatches in those components, and it does affect the div's own direct text and attributes. People add it at the wrong level, see the warning persist, and add more of them.

typeof window !== 'undefined' inside render. This is the same bug wearing a different hat: it deliberately makes the client render differ from the server render on the first pass. Use it in an effect, or not at all.

Wrapping everything in dynamic(..., { ssr: false }). It does fix the mismatch, by deleting server rendering for that subtree. You pay in blank first paint, layout shift and anything a crawler was going to read. Reach for it when a component genuinely cannot render on the server, not because a date is off by three hours.

Generalising from the theme-switcher case. suppressHydrationWarning on <html> or <body> is legitimate and common: a blocking inline script sets a class before React boots, or a browser extension injects attributes into <body>. There you want the pre-existing DOM value to win. That is the opposite of wanting the client value to win, and the same attribute cannot do both.

When it is still broken

  1. The value flashes correct, then reverts. Something is re-rendering with the server-derived value again, usually a parent that re-mounts the child by changing its key, or a cached RSC payload being reapplied on a client navigation.
  2. It works locally, fails on the deployed build. Check the container's ICU data and TZ. A slim Node image without full ICU will format differently to your laptop, which produces a mismatch you cannot reproduce.
  3. The warning names an element you did not write. Suspect a browser extension or an injected analytics tag. Reproduce in a clean profile before changing any component code.
  4. You need the attribute and the client value. Then you have two requirements in one node. Split it: an element whose content is stable and suppressed, and a sibling that renders the dynamic value after mount.

The honest framing is that this is not an obscure edge case and it is not unfixed. It is one documented sentence, buried under a thousand blog posts that treat the attribute as a mute button. If you have suppressHydrationWarning anywhere in your codebase on an element whose text is supposed to change on the client, go and look at it now.

Reference: React hydrateRoot documentation and the open report at vercel/next.js issue 61911.

Frequently asked questions

Does suppressHydrationWarning only hide the console warning?
No. The React documentation for hydrateRoot states that React will not attempt to patch mismatched text content when the attribute is set. By default React repairs the mismatch and warns; with the attribute it does neither, so the server-rendered text stays in the DOM. If nothing re-renders that component afterwards, the server value is permanent.
Why is my Next.js timestamp still showing the server timezone even though the client code runs?
Because the client render result is being discarded. With suppressHydrationWarning on the element, React adopts the server text instead of replacing it, so your console.log fires and the DOM never changes. Remove the attribute, make the first client render identical to the server render by pinning both the locale and the timeZone in Intl.DateTimeFormat, then update the value inside useEffect.
When is suppressHydrationWarning actually the right tool?
When you want the existing DOM value to win, not the React value. The two common legitimate cases are a blocking inline theme script that sets a class on the html element before React boots, and browser extensions injecting attributes into body. It is the wrong tool whenever the client-computed value is the one you want the user to see.
Does suppressHydrationWarning apply to child elements?
No, it works one level deep only, covering that element's own attributes and its direct text content. Putting it on a wrapper div will not silence a mismatch inside a nested component, which is why people often add several and still see warnings. Put it on the exact element that mismatches, or better, remove the mismatch instead.
#Next.js#React#Hydration#SSR#Intl
Keep reading

Related articles