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.
| Code | HP3002 |
|---|---|
| Name | nested-interactive |
| Default severity | Error |
| Group | Markup the browser repaired |
| What it means | Links, 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:
| Nesting | What 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.
- 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.
- Put buttons side by side. A header button and its close button sit next to each other in a wrapper element.
- Associate controls with a form outside. A button or input can belong to
a form it is not inside, with the
formattribute.
A card with a link inside a link:
// 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>
);
}// 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>
);
}.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:
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.