Hydration Proof

Search documentation

Find a page or section

HP3002: Interactive element nested in another

HP3002 (nested-interactive) means a link is inside a link, a button inside a button or a form inside a form. How the browser splits them and how to fix it.

HP3002 (nested-interactive) means the server HTML puts a link inside another link, a button inside another button or a form inside another form. HTML does not allow it, so the browser splits the elements while parsing and the DOM no longer matches React's tree. Move the inner element out of the outer one.

CodeHP3002
Namenested-interactive
Default severityError
GroupMarkup the browser repaired
What it meansLinks, buttons and forms cannot contain another element of the same kind.

What the HP3002 nested interactive finding means

It is a special case of HP3001: the same check of the server HTML against React's nesting rules, reported under its own code when the inner element has the same tag as the outer one (<a>, <button>, <form>, or the legacy <nobr>).

The browser repairs each of them differently:

NestingWhat the parser does
<a> in <a>Closes the outer link when the inner one starts, so the two become siblings
<button> in <button>Closes the outer button, so the inner one moves after it
<form> in <form>Drops the inner <form> tag; its fields belong to the outer form

React then hydrates a DOM with a different shape than it rendered. The finding is an error when the browser moved the element, and a warning when this browser kept the nesting.

The React error it corresponds to

In development, React logs the nesting before it reports the hydration error:

In HTML, <a> cannot be a descendant of <a>. This will cause a hydration error.
<a> cannot contain a nested <a>
validateDOMNesting(...): <a> cannot appear as a descendant of <a>

The validateDOMNesting page lists the other variants.

Likely causes

The cause is invalid HTML nesting, usually one of:

  • A card that is one big link and also contains another link or a "Save" button.
  • A <Link> component inside another <Link>: both render <a>.
  • A disclosure or tab button that contains a close or menu button.
  • A form component used inside a page that already wraps its content in a <form>.

How to fix it

Do not nest links inside links, buttons inside buttons or forms inside forms.

  1. Make the card a container, not a link. Put the main link on the title and stretch it over the card with CSS, and keep the other link or button as a sibling.
  2. Put buttons side by side. A header button and its close button sit next to each other in a wrapper element.
  3. Associate controls with a form outside. A button or input can belong to a form it is not inside, with the form attribute.

A card with a link inside a link:

components/post-card.tsx
// Before: the browser closes the outer <a> when the inner one starts
export function PostCard({ post }: { post: Post }) {
  return (
    <a href={`/posts/${post.slug}`} className="card">
      <h3>{post.title}</h3>
      <a href={`/authors/${post.author.slug}`}>{post.author.name}</a>
    </a>
  );
}
components/post-card.tsx
// After: one link per destination; CSS makes the title link cover the card
export function PostCard({ post }: { post: Post }) {
  return (
    <article className="card">
      <h3>
        <a href={`/posts/${post.slug}`} className="card-link">
          {post.title}
        </a>
      </h3>
      <a href={`/authors/${post.author.slug}`} className="card-author">
        {post.author.name}
      </a>
    </article>
  );
}
components/post-card.css
.card {
  position: relative;
}
.card-link::after {
  content: "";
  position: absolute;
  inset: 0;
}
.card-author {
  position: relative;
  z-index: 1;
}

A delete button that needs its own form, inside an edit form:

components/edit-item.tsx
export function EditItem({ id }: { id: string }) {
  return (
    <>
      <form action="/items/save" method="post">
        <input name="title" />
        <button type="submit">Save</button>
        <button type="submit" form={`delete-${id}`}>
          Delete
        </button>
      </form>
      <form id={`delete-${id}`} action={`/items/${id}/delete`} method="post" />
    </>
  );
}

Catch it with ESLint

no-invalid-interactive-nesting reports <a> in <a>, <button> in <button>, <form> in <form> and other interactive content inside links and buttons, in Server Components as well. Nesting that crosses a component boundary is only visible in the server HTML, which is where this finding comes from.

When you cannot change the markup yet

Add an ignore.issues rule with code: "HP3002", a reason and an expires date; see ignoring findings. The page still hydrates with a different DOM until the markup is fixed.