Development

Astro 5 Hydration Mismatch: Causes and Fixes [2026]

Asep Alazhari

Seeing hydration mismatch warnings in your Astro 5 islands? Here is why SSR and client markup drift apart, and the fixes that actually work in production.

Astro 5 Hydration Mismatch: Causes and Fixes [2026]

The Console Warning That Made Me Doubt My Code

I was shipping a small dashboard built on Astro islands last month. Everything looked fine in the browser. Then I opened the console and saw a wall of hydration mismatch warnings, the kind that complain about text content not matching between the server and the client. The page still worked. Nothing crashed. But that warning kept showing up on every reload, and it nagged at me.

I figured it was a one off. I refreshed. It came back. I tried a different page. Same warning, different component. At that point I knew this was not a fluke. Something in my islands was rendering one thing on the server and a different thing in the browser, and Astro was telling me about it the only way it could.

If you have seen a warning like “Hydration completed but contains mismatches” or “Text content does not match server rendered HTML” in your Astro project, you are not alone. This is one of the most common issues developers run into once they start mixing server rendered pages with interactive islands. Let’s walk through why it happens and how to fix it for good.

What Hydration Mismatch Actually Means in Astro 5

Server Rendering vs Client Hydration

Astro renders your pages on the server first. It builds the HTML, sends it to the browser, and the page appears instantly. That is the whole point of the islands architecture, and it is a big part of why Astro works so well for high-performance blogs. Most of your page stays static HTML with zero JavaScript.

Then, for the parts you marked as interactive, the browser downloads the component code and hydrates it. Hydration means the framework takes the existing server rendered HTML and attaches event listeners and state to it, instead of throwing it away and rendering from scratch.

For hydration to work cleanly, the markup the client renders on its first pass has to match the markup the server already sent. If the two do not match, the framework has to repair the DOM on the fly. That repair step is what triggers the warning you are seeing, and in some cases it can cause flicker, lost focus, or broken interactivity.

How Island Directives Change the Picture

Astro gives you several client directives to control when and how a component hydrates:

  • client:load hydrates as soon as the page loads
  • client:idle waits until the browser is idle
  • client:visible waits until the component scrolls into view
  • client:only skips server rendering entirely and renders only in the browser

Each of these changes the timing of hydration, but none of them change the requirement that the first client render has to match what the server produced, except for client:only, which sidesteps the problem by never rendering on the server in the first place. Picking the wrong directive for a component that depends on browser only data is one of the fastest ways to end up with a mismatch.

Also Read: Astro Background Caching and CDN Image Optimization

The Usual Suspects Behind Hydration Mismatch

Once I started digging, I found that almost every hydration warning traces back to one of four causes.

Time and Random Values

Anything that produces a different value each time it runs is dangerous in server rendered components. Date.now(), new Date(), and Math.random() will return one value when the server renders the page and a different value the moment the browser hydrates it.

// This will mismatch almost every time
export default function Timestamp() {
    return <span>Rendered at {Date.now()}</span>;
}

The server stamps the HTML with one number. The client stamps its first render with another. Astro compares them, finds a difference, and logs the warning.

Browser Only Globals

window, document, localStorage, and navigator do not exist on the server. If your component reads from any of them during the initial render, the server has nothing to work with and falls back to a default, while the client immediately has the real value.

// window is undefined on the server
export default function ThemeBadge() {
    const theme = window.localStorage.getItem("theme") ?? "light";
    return <span className={`badge badge-${theme}`}>{theme}</span>;
}

The server renders badge-light. The client, with access to localStorage, might render badge-dark. That difference is exactly what triggers the warning.

Locale and Timezone Drift

This one is sneaky. If your server runs in UTC and your visitor is in a different timezone, formatting a date with toLocaleString or toLocaleDateString can produce two different strings for the exact same timestamp.

// Same Date object, different output depending on the runtime
<span>{new Date(post.publishDate).toLocaleDateString()}</span>

I hit this exact issue on this very blog. An article published at 09:00 in Jakarta time showed one date on the server rendered page and a slightly different one after hydration for visitors in other timezones.

Conditional Rendering Based on Browser State

Anything that branches on screen size, connection speed, or feature detection will render differently depending on whether it runs on the server or in the browser.

// matchMedia does not exist during server rendering
const isMobile = typeof window !== "undefined" && window.matchMedia("(max-width: 768px)").matches;
return isMobile ? <MobileNav /> : <DesktopNav />;

On the server, typeof window is "undefined", so it always renders DesktopNav. The instant it hydrates in a small viewport, it swaps to MobileNav, and the mismatch warning fires before the swap even finishes.

How to Diagnose It Fast

Before jumping into fixes, narrow down which component is causing the problem. A few habits saved me a lot of guesswork.

  1. Open the browser console and read the warning carefully. Modern frameworks usually print the exact tag or text node that did not match.
  2. View the page source, not the rendered DOM, and compare it side by side with what shows up after the page finishes loading. Differences jump out quickly once you place them next to each other.
  3. Comment out islands one at a time, starting with the ones that touch dates, random values, or window. If the warning disappears, you found your suspect.
  4. Search your component for Date, Math.random, window, document, localStorage, and matchMedia. In my experience, these five terms cover the majority of hydration bugs.

Fixing Hydration Mismatch Step by Step

Fix 1: Defer Browser Only Logic Until After Mount

The most reliable fix is to render a safe default on the first pass, then update the component once it is mounted in the browser. In React, that means reading browser only values inside useEffect and storing them in state.

import { useEffect, useState } from "react";

export default function ThemeBadge() {
    const [theme, setTheme] = useState("light");

    useEffect(() => {
        const stored = window.localStorage.getItem("theme");
        if (stored) setTheme(stored);
    }, []);

    return <span className={`badge badge-${theme}`}>{theme}</span>;
}

The server and the first client render both produce badge-light. Hydration succeeds cleanly. Then, once the component mounts, useEffect runs, the real value loads, and the component updates. The user sees a correct theme within a frame or two, and there is no warning in the console.

Fix 2: Pick the Right Client Directive

If a component genuinely cannot be rendered the same way on the server and the client, do not fight it. Use client:only and tell Astro to skip server rendering for that island entirely.

---
import ThemeBadge from "../components/ThemeBadge";
---

<ThemeBadge client:only="react" />

This is the right call for components that are deeply tied to browser state, such as ones reading from localStorage, geolocation, or matchMedia on first render. You lose the instant first paint for that one component, but you remove the mismatch entirely. For small, non critical widgets, that trade is usually worth it. If you are assembling interactive widgets on top of Astro, the component patterns in my Astro and shadcn/ui integration guide pair nicely with this approach.

Also Read: ERR_UPLOAD_FILE_CHANGED in Next.js: Complete Fix Guide

Fix 3: Render a Stable Placeholder on the Server

For components that depend on values like screen width, render a neutral placeholder during server rendering and swap it out after the component mounts.

import { useEffect, useState } from "react";

export default function ResponsiveNav() {
    const [mounted, setMounted] = useState(false);
    const [isMobile, setIsMobile] = useState(false);

    useEffect(() => {
        setMounted(true);
        const mql = window.matchMedia("(max-width: 768px)");
        setIsMobile(mql.matches);

        const handler = (event: MediaQueryListEvent) => setIsMobile(event.matches);
        mql.addEventListener("change", handler);
        return () => mql.removeEventListener("change", handler);
    }, []);

    if (!mounted) return <DesktopNav />;
    return isMobile ? <MobileNav /> : <DesktopNav />;
}

The server and the first client render both show DesktopNav. Hydration matches. Right after mounting, the component checks the real viewport and swaps if needed. Most users will not even notice the swap, and your console stays clean.

Fix 4: Move Time Sensitive Values to the Client

For timestamps and relative dates, format them once the component is mounted, instead of during the initial render. You can also normalize the timezone on the server so both renders agree.

import { useEffect, useState } from "react";

export default function PublishedAt({ iso }: { iso: string }) {
    const [label, setLabel] = useState(() => new Date(iso).toISOString().slice(0, 10));

    useEffect(() => {
        setLabel(new Date(iso).toLocaleDateString());
    }, [iso]);

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

The first render always uses the ISO date string, which is identical on the server and the client. Once mounted, the component switches to the locale formatted version that matches the visitor’s browser. No mismatch, and the date still looks native to each reader.

Testing Your Fix

Once you have applied a fix, confirm it actually holds up.

  1. Run a production build and preview it locally with astro build and astro preview. Development mode is sometimes more forgiving than production.
  2. Open the page with the browser console open and reload several times. The warning should be gone on every load, not just most of them.
  3. Throttle the network in devtools and reload again. Slower connections widen the gap between server render and hydration, which is exactly when mismatches tend to surface.
  4. Test with different system locales and timezones if your component touches dates. Changing your operating system locale for five minutes can reveal bugs that would otherwise only show up for visitors abroad.

Frequently Asked Questions

Does a hydration mismatch warning mean my site is broken? Not always. The page usually keeps working because the framework repairs the DOM after detecting the mismatch. But that repair costs time, can cause visible flicker, and sometimes drops event listeners or focus state. Treat the warning as a signal to fix, not as something safe to ignore long term.

Why does this only show up in production and not in development? Development builds often run with extra checks and slower, more forgiving rendering paths. Production builds are leaner and stricter about timing, so subtle differences between server and client output are more likely to surface as real mismatches once your site is live.

Can I just silence the warning instead of fixing it? Some frameworks offer an escape hatch similar to suppressHydrationWarning that hides the message for a single element. That only hides the symptom. The server and client still render different content, which means your users still see a flash of incorrect data before the swap happens. Fix the source instead of muting the signal.

Does switching to client:only hurt performance? It removes the server rendered version of that one island, so visitors see a brief loading state instead of instant content for that component. For small interactive widgets, this trade is usually invisible. For large, content heavy sections, prefer the placeholder approach from Fix 3 so you keep the fast first paint.

Is this issue specific to React islands in Astro? No. The same root cause applies to Vue, Svelte, Solid, and Preact islands, and to any framework that hydrates server rendered HTML. The fixes in this guide, deferring browser only logic, picking the right rendering strategy, and keeping the first render deterministic, apply across all of them.

Wrapping Up

Hydration mismatch warnings look scary the first time you see a wall of red text in the console, but the underlying cause is almost always simple. Something in your component produces a different result on the server than it does in the browser, usually because of time, randomness, browser only globals, or conditional rendering based on client state.

Once you know what to look for, fixing it is mostly a matter of making the first render deterministic and deferring anything browser specific until after the component mounts. Apply that pattern consistently across your islands, and those warnings will disappear from your console for good.

If you are still chasing a stubborn mismatch after trying these fixes, isolate the component, strip it down to the smallest version that reproduces the warning, and rebuild it piece by piece. Nine times out of ten, the culprit turns out to be one line you would never have suspected.

Back to Blog

Related Posts

View All Posts »
ERR_UPLOAD_FILE_CHANGED in Next.js: Complete Fix Guide [2026]
Development

ERR_UPLOAD_FILE_CHANGED in Next.js: Complete Fix Guide [2026]

Only seeing ERR_UPLOAD_FILE_CHANGED in production but not on localhost? This guide explains the exact root cause — FormData lifecycle and file reference expiry — with a step-by-step fix to stop this Next.js file upload bug for good.