In a Vue 3 SSR app, why does a template containing `<p><div>Hi</div></p>` cause a hydration mismatch even though the server rendered exactly that?
answer
- who turns the HTML string into DOM
- paragraph content model
- parser closes the p early
- DOM no longer matches the vnode tree
- dev compiler nesting warning
basics
~20 sThe browser's HTML parser, not Vue, turns the server string into DOM, and it closes the paragraph before the div. The resulting DOM no longer matches the vnode tree the client renders, so Vue reports a mismatch and rebuilds those nodes.
solid answer
~40 sVue's server renderer only concatenates strings, so it sends `<p><div>Hi</div></p>` verbatim. The browser parses that by the HTML spec: a `<div>` cannot sit inside a `<p>`, so the parser closes the paragraph, places the `<div>` after it, and turns the stray `</p>` into a second empty `<p>`. When the client app mounts over that DOM, Vue walks it against its own vnode tree, finds an empty `<p>` and extra siblings, warns about a children mismatch in development, mounts the missing `<div>` with DOM calls, and replaces or removes the leftover server nodes it meets next. A client-only app never shows the bug because `createElement` and `insertBefore` do not apply parser rules. The fix is valid markup, and Vue's template compiler already warns in dev builds that `<div>` cannot be child of `<p>`.
code
vue · 14 lines<script setup lang="ts">
defineProps<{ title: string; body: string }>()
</script>
<template>
<!-- Invalid: the parser closes the <p> before the <div> -->
<!-- <p class="intro"><div class="title">{{ title }}</div>{{ body }}</p> -->
<!-- Valid: block container for block content -->
<div class="intro">
<div class="title">{{ title }}</div>
<p>{{ body }}</p>
</div>
</template>go deeper
Recall that the browser parser, not Vue, builds the DOM from the server string, and that p cannot contain div. Name the fix: valid nesting.
Walk through what hydration finds: an empty paragraph, extra siblings, children mismatch warnings, then mounting and removal. Explain why client-only rendering hides the bug.
Point out the component-root variant the compiler cannot see, and that production shows only a generic error, so review and dev-mode SSR checks must catch it.
Frame valid markup as a contract between components: a design-system component's root element constrains where it may be placed, and that belongs in its documentation or lint rules.
## Two different machines build the DOM In a Vue 3 app rendered on the server, the page's DOM is produced twice, by two different mechanisms: - **On the server**, `vue/server-renderer` turns the component tree into a **string of HTML**. It does not build a DOM and it does not validate markup; it writes out what the template says. - **In the browser**, that string is handed to the **HTML parser**, which follows the HTML specification's parsing rules, including its error-recovery rules for invalid nesting. - **Hydration** then runs the same components again on the client, renders a **vnode tree**, and walks the existing DOM node by node, expecting each DOM node to correspond to the next vnode. If the parser rewrote the markup, the DOM Vue walks is no longer the structure the template described, even though the server sent exactly that structure. ## What the parser does with `<p><div>` A `<p>` element may only contain phrasing content (text, `<span>`, `<a>`, `<em>` and similar). When the parser meets a `<div>` start tag while a `<p>` is open, it **implicitly closes the paragraph**. The server string ```html <p><div>Hi</div></p> ``` becomes this DOM: ```html <p></p> <div>Hi</div> <p></p> ``` The trailing `</p>` has no open paragraph to close, so the parser creates a new, empty one. There was no error message: HTML parsers never reject a page, they repair it silently. ## What Vue sees during hydration Vue's client render still produces a `p` vnode with one `div` child. The walk then goes roughly like this: 1. Vue matches the first `<p>` and looks for its first child; the DOM `<p>` is empty. 2. In development it warns `Hydration children mismatch` because the server-rendered element contains fewer child nodes than the client vdom, and it **mounts** the missing `<div>` inside the `<p>` with DOM calls. 3. If the paragraph sits inside a parent element Vue is hydrating, Vue next meets the parser-created `<div>` and second `<p>` where it expected the paragraph's next sibling (or nothing), reports node or children mismatches, and **replaces or removes** them. The page ends up with the structure the client asked for, because DOM APIs such as `createElement` and `insertBefore` are not subject to the parser's content-model rules. But the server-rendered nodes for that subtree were thrown away and rebuilt, which is exactly the work hydration exists to avoid, and the user may see a flash while it happens. In a production build none of the detail is printed: Vue logs a single generic `Hydration completed but contains mismatches.` error. ## Invalid nestings that trip this | Server markup | What the browser parser builds | |---|---| | `<p><div>…</div></p>` | empty `<p>`, the `<div>`, another empty `<p>` | | `<p><ul>…</ul></p>` | same pattern: lists also close an open paragraph | | `<table><tr>…</tr></table>` | an implicit `<tbody>` is inserted around the row | | `<a href="/x"><a href="/y">…</a></a>` | the outer link is closed before the inner one opens | | `<form><form>…</form></form>` | the inner form start tag is ignored | The same bug often hides inside components: a `<BaseCard>` whose root is a `<div>`, placed inside a `<p>` in a parent template, produces the same parser repair even though neither template looks wrong on its own. ## How Vue helps you catch it - **At compile time (development only):** Vue's DOM template compiler validates element nesting and warns, for example, `<div> cannot be child of <p>, according to HTML specifications. This can cause hydration errors or potentially disrupt future functionality.` It checks element-to-element nesting inside one template, so a component whose root element is the problem is not caught this way. - **At hydration (development builds):** children and node mismatch warnings name the element where the DOM and the vdom diverged. - **In production:** only the generic error, which is why this class of bug should be fixed before it ships. ## Fixing it - Use a container that allows block content: `<div>` inside `<div>`, or `<span>` inside `<p>`. - Write the `<tbody>` explicitly in tables. - When a component is placed inside a `<p>`, check what element its root renders. - Do not reach for `data-allow-mismatch`: it silences the report, but Vue still discards and remounts the nodes on every page load, and the markup stays invalid.
- How does Vue recover from the malformed DOM, and what does that cost?Vue mounts the vnodes the DOM is missing and removes the server nodes the client did not render, so the final structure matches the client render. The cost is that the server-rendered subtree is discarded and recreated with DOM calls on every page load, which can flash and wastes the work SSR did. Production builds log only a generic mismatch error.
- Why does `<table><tr>` in a Vue template cause the same problem?The HTML parser inserts an implicit `<tbody>` between `<table>` and `<tr>`, so the DOM gains an element the vnode tree does not have. Vue's dev compiler warns that `<tr>` cannot be child of `<table>`. Writing the `<tbody>` explicitly keeps server HTML and client vnodes aligned.
- Can the compiler's nesting warning miss a case?Yes. It checks element-to-element nesting inside one template. If a parent places `<MyCard>` inside a `<p>` and `MyCard`'s root is a `<div>`, neither template is invalid on its own, so only the hydration-time warnings reveal the repair.
saying these in an interview costs you the question
- The server renderer must have produced broken HTML
- Browsers render invalid nesting exactly as written
- It works when mounted client-side, so the template is fine
- Mismatches only come from data differences, never from markup
- Adding data-allow-mismatch is the proper fix for invalid nesting