# validateDOMNesting: invalid HTML nesting warnings

> validateDOMNesting warnings mean the browser will rearrange your HTML: a row without tbody, text in a tr, a link in a link. What each says and how to fix it.

Source: https://hydration.jscrate.dev/docs/errors/validatedomnesting
Last updated: 2026-09-18

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:

```text
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:

```text
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(...)`:

```text
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`:

```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](https://hydration.jscrate.dev/docs/errors/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 markup                                                    | What 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 button                                           | Closes 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:

   ```tsx title="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:

   ```tsx title="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:

   ```tsx title="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:

   ```tsx title="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](https://hydration.jscrate.dev/docs/issues/hp3001), and a link, button or form inside another as
[HP3002](https://hydration.jscrate.dev/docs/issues/hp3002). It sees nesting across components too:

```bash
npx hydration-proof test
```

In the editor,
[`no-invalid-interactive-nesting`](https://hydration.jscrate.dev/docs/rules/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.

## Related

- [div cannot be a descendant of p](https://hydration.jscrate.dev/docs/errors/div-cannot-be-a-descendant-of-p)
- [Invalid HTML nesting as a cause of hydration errors](https://hydration.jscrate.dev/docs/causes/invalid-html)
- [HP3002: interactive element nested in another](https://hydration.jscrate.dev/docs/issues/hp3002)
- [Button inside button: the ESLint rule](https://hydration.jscrate.dev/docs/rules/no-invalid-interactive-nesting)
- [Other JSX nesting linters compared](https://hydration.jscrate.dev/docs/compare/eslint-plugin-validate-jsx-nesting)
