Sooner or later a Nuxt app prints Hydration text content mismatch in the browser console. The page looks fine, so it is tempting to ignore. A mismatch means the server and the browser rendered the page differently, and Vue had to settle the difference, sometimes by keeping the server's version.
Once you know what the server sends and what the browser does with it, you can read that warning, trace it to its cause and fix it there instead of hiding it. The examples use Nuxt 4.3 with Vue 3.5 and the Nuxt 4 folder layout, with pages and components inside app/.
One request, two renders
SSR, server-side rendering, means the server runs your Vue components and sends finished HTML. Nuxt does this by default; its docs call it universal rendering. Take this page:
<script setup lang="ts">
const { data: posts } = await useFetch('/api/posts')
const likes = ref(0)
function like() {
likes.value++
}
</script>
<template>
<ul>
<li v-for="post in posts" :key="post.id">{{ post.title }}</li>
</ul>
<button type="button" @click="like">Like ({{ likes }})</button>
</template>
Here is what happens when someone opens it:
- The server renders the page. It runs
<script setup>, calls the API, and renders the list and the button as HTML. It never runslike(), because nobody can click on a server. - The server adds the payload. Into the same HTML it writes a
<script type="application/json" id="__NUXT_DATA__">tag holding the data the page fetched. A library called devalue serialises it, so dates, maps and refs survive the trip. - The browser paints the HTML straight away. The user can read the list before any JavaScript has arrived. The button is there too, but clicking it does nothing yet.
- Vue runs the components again. The app's JavaScript loads and Vue runs the same components in the browser.
useFetchfinds its result in the payload and does not call the API again; this is why the data fetching docs tell you to use it instead of a bare$fetchin setup. - Vue hydrates the page. It walks the DOM the server built, node by node, compares it with what it would have rendered itself, and reuses each node rather than creating a new one. Along the way it attaches the click listener to the button.
That walk in step 5 is hydration. After it, the page is a normal Vue app, and navigating to another page happens in the browser without asking the server for HTML.
Hydration only works if the second render produces the same output as the first. When it does not, you have a hydration mismatch.
Reading a mismatch warning
Here is a component that looks harmless:
<script setup lang="ts">
const props = defineProps<{ publishedAt: string }>()
const label = new Date(props.publishedAt).toLocaleString()
</script>
<template>
<p>Published {{ label }}</p>
</template>
toLocaleString() formats a date with the locale and time zone of the machine running it. Say the server runs in UTC with US English, and the reader is in Berlin. The server writes Published 2/10/2026, 9:00:00 AM. The browser computes Published 10.2.2026, 10:00:00. In development Vue reports it like this:
- the first line,
[Vue warn]: Hydration text content mismatch on, followed by the element itself, here the<p>; - a line starting
- rendered on server:with the text the HTML contained; - a line starting
- expected on client:with the text the browser computed.
The element tells you where to look, and the two values usually tell you why. When two strings differ only in format, suspect a date or a number. When they differ completely, suspect random values or state that only exists in the browser.
What Vue does when the renders disagree
A mismatch is not always harmless. What Vue does about it depends on what differs, and the rules are in Vue's hydration code:
- Text. Vue overwrites the server's text with its own, so the page shows the right value after a visible flicker.
- The element itself. Vue throws the server's node away and mounts a fresh one. That costs time and loses the benefit of SSR for that part of the page.
- A class, style or attribute. Vue only reports it. The warning says the DOM "will not be rectified in production", so the user keeps the server's version.
In production builds the detailed warnings are dropped, and class, style and attribute differences are not checked at all. For text and element mismatches Vue logs one line, Hydration completed but contains mismatches., which names no element. Find mismatches in development, where the warning points at the element.
Where mismatches come from
The same few causes come up again and again:
- Dates and numbers. Formatting depends on locale and time zone, and the server rarely shares either with the reader.
- Random values.
Math.random()or a generated ID gives a different result on each run. For element IDs, use Vue'suseId(), which produces the same ID on the server and in the browser. - Browser-only APIs.
localStorage,window.innerWidthandmatchMediado not exist on the server. Code that reads them in setup either crashes there or renders a default the browser then contradicts. - Invalid HTML. A
<div>inside a<p>is not allowed, so the browser's parser closes the paragraph early and the DOM no longer has the shape Vue rendered. - Browser extensions. Some extensions insert elements or attributes into the page before Vue hydrates. If a warning only appears in your everyday browser, try a private window with extensions off before you debug your code.
Fix the cause before you hide the warning
Try these in order. The first two remove the difference; the last two work around it.
First, make both renders produce the same output. For the date, give the formatter a fixed locale and time zone:
<script setup lang="ts">
const props = defineProps<{ publishedAt: string }>()
const formatter = new Intl.DateTimeFormat('en-GB', {
dateStyle: 'long',
timeZone: 'Europe/London',
})
const label = computed(() => formatter.format(new Date(props.publishedAt)))
</script>
<template>
<p>Published {{ label }}</p>
</template>
Server and browser now both print Published 10 February 2026. When you do want the reader's own locale and time zone, use Nuxt's <NuxtTime :datetime="publishedAt" />. It renders a <time> element on the server and adds a tiny script that reformats the text in the browser just before Vue hydrates, so the two renders already agree when Vue compares them.
Second, compute a random value once and ship it. useState creates state that is serialised into the payload, so the browser reuses the server's value instead of rolling again:
<script setup lang="ts">
const tips = ['Name refs after what they hold', 'Keep props read-only', 'Prefer computed over watch']
const index = useState('tip-index', () => Math.floor(Math.random() * tips.length))
</script>
<template>
<p>Tip: {{ tips[index] }}</p>
</template>
Third, for content that genuinely depends on the browser, render it only there. Read localStorage in onMounted, which never runs on the server, or wrap the component in <ClientOnly>:
<template>
<ClientOnly>
<RecentlyViewed />
<template #fallback>
<p>Loading your recent pages…</p>
</template>
</ClientOnly>
</template>
The server sends the fallback and the browser swaps in the real component after hydration. The default slot is removed from the server build entirely. Use this sparingly: what sits inside appears late, and crawlers that do not run JavaScript never see it.
Last, when a difference is expected and harmless, say so. Vue 3.5 added the data-allow-mismatch attribute: <span data-allow-mismatch="text">{{ lastSeen }}</span> silences text mismatches inside that element. Its other values are children, class, style and attribute, and an empty value allows all of them. It only silences the warning. Vue still corrects the text, so the flicker remains, which is why it comes after the real fixes.
Delay hydration until a component is needed
Hydration has a cost even when nothing mismatches: every component's JavaScript must load and run before its part of the page responds. Nuxt lets you postpone that for components that do not need to be interactive straight away. Use the component with Nuxt's Lazy prefix (CommentList becomes LazyCommentList) and add a strategy prop:
<template>
<ArticleBody />
<LazyCommentList hydrate-on-visible />
</template>
The comments are in the HTML from the first byte, so readers and crawlers see them. Their JavaScript loads and runs only when the list scrolls into view. The other strategies are hydrate-on-idle, hydrate-on-interaction, hydrate-on-media-query, hydrate-after, hydrate-when and hydrate-never. The components docs warn that any prop change hydrates the component at once, and that the prop must be written in the template, not spread with v-bind.
When you import the component yourself instead of relying on auto-imports, the defineLazyHydrationComponent macro does the same job:
<script setup lang="ts">
const LazyHydrationComments = defineLazyHydrationComponent(
'visible',
() => import('~/components/CommentList.vue'),
)
</script>
<template>
<LazyHydrationComments :hydrate-on-visible="{ rootMargin: '200px' }" />
</template>
Pass the strategy and the import inline, as here. The docs warn that the compiler does not recognise the macro if you pass them in through variables. Keep anything above the fold on normal hydration; delaying a button the reader sees first only makes it feel broken.
Turning SSR off for a route
Some pages gain nothing from server rendering: an admin dashboard behind a login, an editor that lives on browser APIs. Route rules switch SSR off for a path:
export default defineNuxtConfig({
routeRules: {
'/admin/**': { ssr: false },
},
})
Those pages arrive as an empty shell and render entirely in the browser, so there is nothing to hydrate and nothing to mismatch. The price is a blank screen until the JavaScript has loaded, and an empty page for crawlers that do not run JavaScript. The docs also note that per-route rendering modes are not available with nuxt generate. Use it for whole sections that are private or browser-bound, never as a quick way to silence one warning on a public page.