Hydration Proof

Search documentation

Find a page or section

validateDOMNesting: invalid HTML nesting warnings

Tables, nested links and buttons: markup the browser rewrites.

validateDOMNesting is the check React runs on your elements against HTML's nesting rules. React 18 prints its warnings as validateDOMNesting(...), and React 19 starts them with "In HTML,". Each one means the browser will rearrange the server HTML, for example a row without <tbody> or a link inside a link, so hydration will fail. Fix the markup.

The error

React 19 prints the rule, then "This will cause a hydration error." on the next line. The most common one is a table row placed directly in a table:

In HTML, <tr> cannot be a child of <table>. This will cause a hydration error.

For that case the full console message puts a hint between the two sentences: "Add a <tbody>, <thead> or <tfoot> to your code to match the DOM tree generated by the browser." The other React 19 messages:

In HTML, <td> cannot be a child of <tbody>.
In HTML, <th> cannot be a child of <thead>.
In HTML, <div> cannot be a child of <tbody>.
In HTML, text nodes cannot be a child of <tr>.
In HTML, whitespace text nodes cannot be a child of <table>. Make sure you don't have any extra whitespace between tags on each line of your source code.
In HTML, <a> cannot be a descendant of <a>.
<a> cannot contain a nested <a>.
In HTML, <button> cannot be a descendant of <button>.
In HTML, <form> cannot be a descendant of <form>.

React 18 prints the same rules as Warning: validateDOMNesting(...):

Warning: validateDOMNesting(...): <tr> cannot appear as a child of <table>. Add a <tbody>, <thead> or <tfoot> to your code to match the DOM tree generated by the browser.
Warning: validateDOMNesting(...): <th> cannot appear as a child of <thead>.
Warning: validateDOMNesting(...): <div> cannot appear as a child of <tbody>.
Warning: validateDOMNesting(...): Text nodes cannot appear as a child of <tr>.
Warning: validateDOMNesting(...): <a> cannot appear as a descendant of <a>.
Warning: validateDOMNesting(...): <button> cannot appear as a descendant of <button>.

Older React versions call a text node #text:

Warning: validateDOMNesting(...): #text cannot appear as a child of <tr>.

All of these are development warnings. In production you only see the hydration error that follows them, #418.

A block element inside a paragraph has its own page: div cannot be a descendant of p.

What validateDOMNesting checks

React renders exactly the tree you wrote. The browser does not: it parses the server HTML with the HTML parser, which repairs markup that breaks the content model before any script runs. React knows those rules and warns about each place where the parser will change your tree:

Your markupWhat the browser does
<tr> directly in <table>Adds a <tbody> around it
<td> or <th> directly in <table>, <tbody> or <thead>Adds a <tr> around it
<div> or text in <table>, <tbody> or <tr>Moves it out, in front of the table
<a> inside <a>Closes the outer link before the inner one starts
A button in a buttonCloses the outer button before the inner one starts
<form> inside <form>Drops the inner <form> tag

"Child" in the message means a direct child, and "descendant" means anywhere inside. When React then hydrates, the DOM has a different shape than the tree it rendered, and hydration fails.

A page rendered only in the browser keeps the invalid tree, because DOM methods do not repair nesting. That is why the problem appears with server rendering.

Common causes

  • Tables without <tbody>. A .map() of rows written directly in <table>.
  • Missing <tr> or <td>. A row component that returns cells, used without a row, or text written straight into a <tr>.
  • A number rendered by &&. {items.length && <td>…</td>} renders the text 0 in the row when the list is empty.
  • Spaces between tags on one line, such as <tr> {cells} </tr>, which JSX keeps as whitespace text.
  • Cards wrapped in a link that contain another link or a button. A <Link> around a card with a "Read more" link or a "Save" button inside.
  • Components that render interactive elements, such as a tooltip trigger that is a <button>, placed inside another <button>.

How to fix it

  1. Tables: write every level. <table>, then <thead> or <tbody>, then <tr>, then <td> or <th>. Before:

    components/orders.tsx
    type Order = { id: string; total: string };
     
    export function Orders({ orders }: { orders: Order[] }) {
      return (
        <table>
          {orders.map((order) => (
            <tr key={order.id}>
              {order.id}
              <td>{order.total}</td>
            </tr>
          ))}
        </table>
      );
    }

    After:

    components/orders.tsx
    type Order = { id: string; total: string };
     
    export function Orders({ orders }: { orders: Order[] }) {
      return (
        <table>
          <tbody>
            {orders.map((order) => (
              <tr key={order.id}>
                <td>{order.id}</td>
                <td>{order.total}</td>
              </tr>
            ))}
          </tbody>
        </table>
      );
    }
  2. Guard with a boolean, not a number. Write {items.length > 0 && …} instead of {items.length && …}.

  3. Take nested links and buttons out of the outer one. Make them siblings. Before, the card is one big link with a button inside:

    components/card-link.tsx
    "use client";
     
    export function CardLink({
      href,
      onSave,
    }: {
      href: string;
      onSave: () => void;
    }) {
      return (
        <a href={href}>
          <h3>Title</h3>
          <button onClick={onSave}>Save</button>
        </a>
      );
    }

    After, the link and the button sit side by side:

    components/card-link.tsx
    "use client";
     
    export function CardLink({
      href,
      onSave,
    }: {
      href: string;
      onSave: () => void;
    }) {
      return (
        <div className="card">
          <a href={href}>
            <h3>Title</h3>
          </a>
          <button onClick={onSave}>Save</button>
        </div>
      );
    }

    To keep the whole card clickable, stretch the link over the card with CSS instead of wrapping the card in it.

Find every instance

hydration-proof compares the server HTML with what the browser parsed and reports each repair with the line in the server HTML: invalid nesting as HP3001, and a link, button or form inside another as HP3002. It sees nesting across components too:

npx hydration-proof test

In the editor, no-invalid-interactive-nesting reports links in links, buttons in buttons, forms in forms, interactive content inside links and buttons, <tr> directly in <table> and cells outside a row, within one component.