Hydration Proof

Search documentation

Find a page or section

Fix the invalid HTML nesting hydration error

React renders what you wrote. The browser parses what HTML allows.

An invalid HTML nesting hydration error happens when the server sends markup that HTML does not allow, such as a <div> inside a <p> or a link inside a link. The browser's parser repairs it while reading the page, so the DOM no longer has the structure React rendered. Fix the nesting so the parser has nothing to rewrite.

Symptoms

React names the elements, in development builds only:

In HTML, <div> cannot be a descendant of <p>.
This will cause a hydration error.
In HTML, <tr> cannot be a child of <table>.
<a> cannot contain a nested <a>.
Warning: validateDOMNesting(...): <div> cannot appear as a descendant of <p>.
Hydration failed because the server rendered HTML didn't match the client.

Production builds show only the generic hydration failure, which is why this cause is easy to miss there. The React-specific messages have their own pages: div cannot be a descendant of p and validateDOMNesting.

hydration-proof reports it as HP3001 (invalid nesting) or HP3002 (an interactive element inside another), with the cause Invalid HTML nesting and the line in the server HTML. It compares the raw server HTML with what the browser's own parser built, so it names this cause with 97% confidence.

Why an invalid HTML nesting hydration error happens

React renders the tree exactly as you wrote it. The browser builds the DOM from the server's HTML with the HTML parser, which follows the HTML rules and fixes what breaks them:

server HTML:  <p>Intro<div>Details</div></p>
browser DOM:  <p>Intro</p><div>Details</div><p></p>

When React hydrates, it expects a <div> inside the <p> and finds it next to it instead. The common repairs:

You renderThe browser buildsFix
<div> (or <ul>, <h2>, <table>, another <p>) inside <p>Closes the <p> before the block, and adds an empty <p> after itUse <span> inside, or make the outer element a <div>
<a> inside <a>Closes the outer link before the inner oneMove the inner link out
<button> inside <button>, <form> inside <form>Closes the outer button; ignores the inner form tagMake them siblings
<tr> directly inside <table>Adds a <tbody> around the rowsRender the <tbody> yourself
<td> directly inside <tbody> or <table>Adds a <tr>Wrap cells in <tr>

How to fix it

Use an inline element inside a paragraph

intro.tsx
// Before: <p>Welcome<div className="details">Read more below.</div></p>
export function Intro() {
  return (
    <div>
      <p>Welcome</p>
      <div className="details">Read more below.</div>
    </div>
  );
}

Watch components that render a block element: <p><Card /></p> breaks if Card returns a <div>. Markdown renderers and CMS rich text often wrap content in <p>, so a component placed inside that content needs to render inline elements.

A card that is a link and also has a button inside is two interactive elements in one. Make them siblings, and stretch the link over the card with CSS if the whole card must be clickable:

card-link.tsx
export function CardLink({
  href,
  onSave,
}: {
  href: string;
  onSave: () => void;
}) {
  return (
    <div className="card relative">
      <a href={href} className="after:absolute after:inset-0">
        <h3>Title</h3>
      </a>
      <button onClick={onSave} className="relative z-10">
        Save
      </button>
    </div>
  );
}

Write the table elements the browser would add

rows.tsx
export function Rows({ rows }: { rows: { id: string; name: string }[] }) {
  return (
    <table>
      {/* Before: <tr> directly inside <table> */}
      <tbody>
        {rows.map((row) => (
          <tr key={row.id}>
            <td>{row.name}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

Stray whitespace between table tags is a text node the table cannot hold. Keep {" "} and line-broken text out of <table>, <tbody> and <tr>.

Catch it with ESLint

no-invalid-interactive-nesting reports every pattern in the table above when it is visible in one component, through fragments, conditionals and .map() callbacks. It checks Server Components too, because React hydrates the elements they render.

npm install -D eslint-plugin-hydration-proof

Nesting across components (<p><Card /></p> where Card renders a <div>) is invisible to a linter.

Catch it in CI

hydration-proof test finds nesting across components, in content from a CMS and in HTML injected with dangerouslySetInnerHTML, because it compares the real server HTML with the DOM the browser's parser built, using React's own nesting rules. It reports HP3001 or HP3002 with the element and its line in the server HTML. No probe is needed: the parser's repair is the proof. See how it works.

npx hydration-proof test