Fixing HTML Errors in React and Next.js
JSX looks like HTML but is not parsed like it. React will happily let you put a div inside a paragraph, a link inside a link or a button inside a card that is already a link, and it only complains in the console.
The browser, however, applies the normal HTML rules to the server-rendered page, rearranges the invalid parts, and React then finds a page that no longer matches what it expected. The HTML Checker validates the output, not the JSX. This guide shows which React and Next.js habits produce invalid HTML, how that links to hydration errors, and how to fix each one in the component.
Why React output fails validation
React builds pages from components, and each component only knows about its own markup. A Card
component does not know it has been placed inside a <p>; a Button does
not know it is inside a link. Each piece is valid on its own, and the combination is not. Because JSX
is compiled to JavaScript function calls rather than parsed as HTML, nothing stops you writing the invalid
combination.
What happens next depends on how the page is rendered:
- Client-side only, for example a Vite single-page app: React creates DOM nodes directly with JavaScript, so the browser's HTML parser never sees the invalid nesting. The structure is exactly what you wrote, invalid or not. The checker only sees it if you validate the rendered page.
- Server-rendered, as with Next.js, Remix or React Router framework mode: React produces an HTML string on the server. The browser parses that string with the normal HTML rules and repairs anything invalid. When React then hydrates, it walks the repaired tree expecting its own structure, finds something different and reports a hydration error.
That second case is the important one. Many hydration errors that look mysterious are simply invalid
HTML nesting. React's own console message now says as much, with warnings such as In HTML, <div>
cannot be a descendant of <p>. This will cause a hydration error. Earlier versions printed
a validateDOMNesting warning for the same thing.
The invalid patterns React makes easy
| Pattern in JSX | Why it is invalid | Valid alternative |
|---|---|---|
A Text component rendering <p> that wraps a component rendering
<div> | Paragraphs may only contain phrasing content. | Let the text component
accept an as prop, or render a div when it has block children. |
A Link around a card that contains another Link | Links cannot contain other interactive content, including links. | Make one link cover the card with a stretched pseudo-element, and keep the inner link separate. |
A Button component inside a Link | A button inside a link is interactive content inside interactive content. | Style the link to look like a button, or use a button that navigates. |
An icon wrapper <div> inside <button> | Buttons may only contain phrasing content. | Use <span> for wrappers inside buttons. |
A form component inside a page-level <form> | Forms cannot be nested. | Use
one form, or place the inner form elsewhere and link fields with the form attribute. |
A row component returning a <div> around <tr> | Only rows may sit inside a table body. | Return the <tr> directly, or a fragment
of several rows. |
{' '} or line breaks between <td> elements | Text, even whitespace, is not allowed directly in a table row. | Remove the whitespace expression; let CSS handle spacing. |
A list component that renders <li> without its own <ul> | List items need a list parent. | Decide which component owns the list element and keep both in it. |
Clickable cards done validly
// One link covers the card; the second link stays a sibling, not a child
export function ArticleCard({ post }) {
return (
<article className="card">
<h3>
<Link href={`/journal/${post.slug}`} className="card-link">{post.title}</Link>
</h3>
<p>{post.excerpt}</p>
<Link href={`/authors/${post.author.slug}`} className="card-author">{post.author.name}</Link>
</article>
);
}
/* CSS: .card { position: relative } .card-link::after { content: ""; position: absolute; inset: 0 }
.card-author { position: relative; z-index: 1 } */
Next.js specifics
Link and nested anchors
Since Next.js 13, the Link component renders its own <a>. Older code
that wrapped an <a> inside Link now errors unless the legacyBehavior
prop is set, and code that works around this by putting a link-rendering component inside Link
produces an anchor inside an anchor. Pass className and other attributes to Link
directly and remove the inner anchor.
The root layout owns html and body
In the App Router, the root app/layout file must render the <html>
and <body> elements. That is where the lang attribute goes. Nested layouts
must not render them again, or the output contains a second html element.
// app/layout.tsx
export const metadata = {
title: { default: 'Larchfield Timber', template: '%s | Larchfield Timber' },
description: 'Garden rooms and summerhouses built to order.',
};
export default function RootLayout({ children }) {
return (
<html lang="en-GB">
<body>{children}</body>
</html>
);
}
Metadata and duplicate head tags
Next.js writes the doctype and the charset meta tag for you and builds the rest of the head from the
metadata export or generateMetadata. Duplicate or conflicting tags appear when
a project also adds its own <head> in a layout, or, in the Pages Router, when several
components use next/head for the same tag without a shared key. Pick one mechanism.
React 19 can also hoist <title>, <meta> and <link>
elements rendered inside components into the head, which is useful but can create duplicates if the framework's
metadata does the same job.
Images
next/image requires an alt prop and outputs a valid <img>.
Errors usually come from wrapping it in a block element inside a paragraph or button, which is the nesting
problem again.
Attributes React writes into the HTML
React translates JSX props into HTML attributes, and most translations are automatic: className
becomes class, htmlFor becomes for, and tabIndex
becomes tabindex. Three kinds of prop cause validation errors.
- Custom props passed through. A styled component or wrapper that spreads all its
props onto a DOM element passes along things like
isActiveorvariant. React warns that it does not recognise the prop on a DOM element, and depending on the value it may still write it into the HTML, where the validator reports an attribute that is not allowed. Destructure custom props before spreading, use transient props where your styling library supports them, or rename genuine custom data todata-attributes. - Booleans in the wrong place.
hidden,disabledandrequiredare real boolean attributes; pass true or false and React adds or omits them. ARIA attributes are not booleans; React writesaria-expanded={true}as the string true, which is correct, butaria-expanded={undefined}leaves it out, which may leave a widget without its required state. - Hard-coded IDs in reused components. A form field component with a fixed
idproduces duplicates as soon as it appears twice. Generate IDs with theuseIdhook. The duplicate ID guide covers this in more depth.
HTML from a CMS
Content from a headless CMS often arrives as an HTML string rendered with dangerouslySetInnerHTML.
If that string contains paragraphs and your component places it inside a <p>, the
output nests paragraphs, the browser splits them, and hydration fails. Render CMS HTML inside a <div>,
and sanitise it on the server both for security and to remove broken markup before it reaches the page.
How to validate a React site
You need to check the HTML the browser actually receives, and for client-rendered parts the DOM after scripts run.
- Server HTML. Run the HTML Checker against the live or staging URL, or fetch the
page with
curland save it. For a static export, the built HTML files are ready to validate directly. - Rendered DOM. For client-only routes, open the page, then in the developer tools
console run
copy(document.documentElement.outerHTML)and paste the result into a validator as direct input. - Console warnings. Run the app in development mode and click through each page type. React prints nesting and attribute warnings with a component stack showing exactly which component rendered the problem element.
# Validate a Next.js static export in CI
npx next build # with output: 'export' in next.config
npx html-validate "out/**/*.html"
html-validate also offers matchers for Jest and Vitest, so a component test can render markup to a string and assert that it is valid. That catches a new nesting error in the pull request that introduces it.
suppressHydrationWarning is meant for single elements whose text
legitimately differs between server and client, such as a timestamp. Using it to silence a hydration
error caused by invalid nesting hides the symptom and leaves the invalid HTML in place.The fix, step by step
Step 1
Validate the output, not the JSX
Run the HTML Checker on the server-rendered page for each route type, such as home, listing, article and checkout. For client-only routes, copy the rendered DOM from the browser console and validate that instead. Group the errors by the element and class names involved, because each group usually maps to one component. Note which errors also appear as hydration errors, since those are affecting real visitors as well as the validator.
Step 2
Clear the nesting warnings in the console
Run the app in development mode and open each page type with the console visible. React reports invalid nesting with messages like cannot be a descendant of, followed by a component stack. Use that stack, or the React Developer Tools components tab, to find which parent and child components combine into invalid HTML. Fix the parent or child so the pair is valid, rather than suppressing the warning or the hydration error it causes.
Step 3
Untangle links, buttons and clickable cards
Search for Link components that wrap cards containing other links or Button
components. Keep one link per destination and make it cover the card with a stretched pseudo-element,
placing any secondary link beside it rather than inside it. Replace buttons inside links with links styled
as buttons. Change block wrappers inside buttons, such as icon div elements, to span.
Remove any <a> placed inside a Next.js Link.
Step 4
Keep CMS HTML out of paragraphs
Find
every place that renders HTML strings with dangerouslySetInnerHTML or a rich text renderer,
and check what element wraps it. Change any p or span wrapper to a div,
because rich text almost always contains paragraphs, lists or headings. Sanitise the HTML on the server
with a maintained sanitiser, which also removes stray or unclosed tags that editors have pasted in, and
confirm the page hydrates without errors afterwards.
Step 5
Stop custom props reaching the DOM
For each unrecognised prop warning, open the component that spreads props onto a DOM element
and destructure the custom props out first, so only valid attributes remain. Use your styling library's
transient prop syntax where it has one, and rename any data you really want in the HTML to a data-
attribute. Replace hard-coded IDs in reusable components with values from useId, and pass
them to the matching label or ARIA attribute.
Step 6
Set lang and metadata in the root layout
In a Next.js App Router project, make sure only the root layout renders html
and body, and set lang="en-GB" or the correct language there. Move titles,
descriptions and canonical links into the metadata export or generateMetadata
rather than hand-written head tags. In the Pages Router, give each next/head tag a key
so later components replace earlier ones instead of duplicating them.
Step 7
Add validation to the test suite
Add a validator such as html-validate to continuous integration, running against a static export, a set of pages fetched from a preview deployment, or component output in unit tests. Fail the build on nesting errors and unknown attributes. Also treat React's nesting and hydration warnings as test failures in development, so a new invalid combination of components is caught before it is merged rather than found later by the HTML Checker.
Edge cases
- Portals. Modals rendered with a portal appear at the end of the body in the DOM, so their markup is validated there, not where the component sits in the React tree. That usually helps validity rather than hurting it.
- Third-party widgets. Chat widgets, review badges and consent banners inject their own markup after load. Errors from them only appear in the rendered DOM and belong to the vendor.
- Streaming and Suspense. With streaming rendering, fallback markup is replaced as data arrives. Validate the finished page as well as the initial response if the two differ significantly.
- Custom elements. Web components used in JSX need a hyphen in their names. Unknown attributes on them are allowed, so the validator is more lenient there.
- SVG in JSX. Attributes such as
strokeWidthare converted tostroke-widthautomatically. Copying raw SVG from a design tool into JSX without converting attributes is a common source of React warnings, not HTML errors.
1. Why does a div inside a p cause a hydration error in Next.js?
Next.js renders the page to HTML on the server, and the browser parses that HTML with the normal rules. A paragraph cannot contain a div, so the parser closes the paragraph before the div and the resulting structure differs from what React produced. When React hydrates, it expects its own tree and finds the repaired one, so it reports a mismatch. Change the outer element to a div, or render the inner content as a span.
2. Does the HTML Checker read my JSX files?
No. It validates the HTML a page produces, which is what browsers, crawlers and assistive technology receive. JSX is compiled to JavaScript and never reaches the browser as markup, so it cannot be validated directly. That is also why components that look fine on their own can produce invalid output together. Run the checker on server-rendered pages, and for client-rendered routes copy the finished DOM from the browser and validate that as direct input.
3. How do I make a whole card clickable without nesting links?
Use one real link, usually on the card's heading, and stretch its clickable area over the whole card with a pseudo-element positioned absolutely inside the card, which is positioned relatively. Any secondary link, such as an author or category, sits beside the main link rather than inside it and is raised above the pseudo-element with a higher stacking order. Screen reader users get a clear link name, and the HTML stays valid.
4. Why do I get a React warning that it does not recognise a prop on a DOM element?
A component is passing a custom prop, such as isActive or variant, straight through to an HTML element, usually by spreading all its props. React warns because HTML has no such attribute, and depending on the value it may still write it into the page, where the validator flags it. Destructure the custom props before spreading the rest, use your styling library's transient prop syntax, or rename genuine custom data to a data attribute.
5. Can I just use suppressHydrationWarning?
Only for the narrow case it was designed for: a single element whose text content legitimately differs between the server and the browser, such as a locally formatted time. It only affects that element's own text, one level deep, and does not fix invalid nesting. Using it to silence errors caused by a div in a paragraph or a link in a link hides the warning while leaving invalid HTML in place for every browser and crawler that reads it.
6. Where does the lang attribute go in a Next.js app?
In the App Router, on the html element rendered by the root layout file, for example lang set to en-GB for British English. Only the root layout should render html and body. In the Pages Router, set it in a custom document, or use the i18n configuration if the site has several locales. A missing lang is a common validator warning on React sites because single-page app templates often ship with a generic html element.
7. Are hydration errors bad for SEO?
They can be. When hydration fails, React may discard the server-rendered HTML and re-render the affected part on the client, which slows the page, can shift layout and costs interactivity time. Search engines render JavaScript, so content usually still gets indexed, but invalid nesting can leave headings, links or structured data in positions you did not intend. Fixing the underlying HTML errors removes the mismatch and gives every crawler the same, valid structure.
The HTML Checker validates the markup your pages actually deliver against W3C standards, reporting nesting, duplicate IDs, ARIA, form and language errors so you can trace each one to the component that produces it.
Open HTML Checker Fixes โ