← Oleksii Turovskyi

Semantic HTML for AI Crawlers: Clean Headings Aren't Enough

· 18 min read · Updated

Semantic HTML for AI crawlers is the discipline of shipping a heading outline that bots like GPTBot, ClaudeBot, and PerplexityBot can parse straight from raw HTML. A clean h1h6 structure is what a retrieval pipeline turns into a document tree before it scores a single paragraph. Clean structure lets a crawler chunk your page correctly; broken structure does not.

TL;DR

  • GPTBot, ClaudeBot, and PerplexityBot fetch raw HTML; none of the three companies documents whether they execute JavaScript, but independent testing shows they don't, so "outline built from h1h6 before scoring a paragraph" is a working assumption here, not a documented vendor fact.
  • A benchmark of 41 pages ranking for this exact topic, run on September 9, 2026, found 32 of them (78%) with a fully clean outline: one h1, no skipped level.
  • The other 9 pages (22%) rank anyway, which means a clean outline looks like a floor most competitors already clear, not a lever that pushes a page above them.
  • Nine of the 41 pages also do not lead with their h1 in the raw DOM. That is a separate defect from multiple h1s or skipped levels, and it overlaps with them on only 3 pages.
  • The rule for a clean outline is short: exactly one h1, no jump of more than one level going down, and jumping back up is always fine.
  • Skipping a level, most often h2 straight to h4, does not summon a phantom heading. The chunk that starts there simply gets filed under the wrong parent.

Do AI crawlers read HTML structure?

Yes, per independent testing, not vendor documentation. Neither OpenAI, Anthropic, nor Perplexity publishes how GPTBot, ClaudeBot, or PerplexityBot parse h1 through h6, so "builds a document outline before scoring a paragraph" is this article's working description of the mechanism, not a fact any of the three states outright.

What their pages don't confirm, traffic data does: Vercel's analysis of more than 500 million GPTBot fetches with MERJ (December 2024) found no JavaScript execution across GPTBot, ClaudeBot, and PerplexityBot. Claude's and ChatGPT's crawlers download JS files on 23.84% and 11.5% of requests but never run them. Google's documentation describes a similar crawl-then-render split, but only for Googlebot, not for the three crawlers above. If your page assembles its h1 and h2 tags client-side, there is nothing in the HTML for a parser to find in the first place, whichever bot arrives. Fix that before fixing anything below.

Why does an LLM parser need a clean outline?

A modern crawler built for an LLM (large language model) works differently than a classic keyword-matching search bot from a decade ago. Instead of extracting keywords and counting their density, it builds the document's semantic tree and slices it into chunks of a fixed size. Every chunk inherits context from its parent headings in this process. This is called hierarchical chunking, and the entire procedure decides whether the model finds your page in response to a user query.

Three concrete things break with bad structure:

  1. The chunk gets filed under the wrong parent. Nothing repairs a skipped level for you. The HTML5 outline algorithm that would have inserted an implied H3 was never implemented in a single browser or assistive technology, and it is gone from the spec. Adrian Roselli's There Is No Document Outline Algorithm is the standing reference, and MDN now simply says "do not skip heading levels." What actually happens is duller and worse than a phantom node: a splitter that anchors on heading levels attaches your H4 to the nearest heading above it, whatever that turns out to be. Skip from H2 to H4 and the chunk inherits the H2, quietly losing the subsection it belonged under. Whether an engine then ranks that chunk lower is a reasonable guess and nothing more. No vendor documents it, and this article's own scan below does not measure it either.
  2. The RAG (retrieval-augmented generation) ranker indexes the wrong place. Production splitters such as MarkdownHeaderTextSplitter in LangChain or a custom regex-based pipeline anchor specifically on heading levels. Two H1 tags on one page mean two "root" documents in the index. Your content gets split. A query that should have returned one cohesive answer returns half of one.
  3. Structure only disorients agents that read one. Anthropic's browser use tool, the one behind Claude in Chrome, reads the accessibility tree by default, the same tree screen readers use for blind users. Broken structure gives it the same disorientation a screen-reader user gets. Computer Use works from screenshots alone, per Anthropic's own documentation, with no accessibility tree involved, so a structurally correct but visually plain heading gives it nothing extra. A11y practices correlate with how well a tree-reading agent performs on your site, not with every agent that touches a browser.

Bottom line: H1 is not "the biggest font." It's the document's topic declaration for machine readers. Treat it accordingly.

What do the pages that already rank for this actually look like?

Every guide to heading hierarchy, this one included until now, argues from principle: here is what a parser does, therefore here is what you should ship. None of them checks what the pages already ranking for the topic actually shipped. On September 9, 2026, I did.

This benchmark pulled the top 20 organic results for three queries in Google's US/English results: semantic html for ai crawlers, heading hierarchy seo ai, and do ai crawlers read html structure. I fetched each URL's raw HTML with curl, executed no JavaScript, and extracted every h1 through h6 with a regular expression. I dropped social and video platforms that ship an empty SPA (single-page application) shell: YouTube, Facebook, LinkedIn, and Reddit. I also dropped two pages that returned a Cloudflare 403, Stack Overflow and one smaller site. That left 41 pages.

Metric Value
Pages in the sample 41
Clean outline: one h1, no skipped level 32 (78%)
Any structural defect 9 (22%)
Pages with more than one h1 6
Pages with a skipped level 6
Most common skip h2 to h4, on 4 pages
Second most common skip h1 to h3, on 3 pages
h1 is not the first heading in the DOM 9 (22%)
Median headings per page 18 (range 1 to 104)

Thirty-two of the 41 pages (78%) run a fully clean outline. The remaining nine split into two overlapping problems: six pages carry more than one h1, and six skip a level, most often jumping straight from h2 to h4. Three pages do both.

A ninth number does not fit neatly into the clean-or-defective split above. On 9 of the 41 pages, the first heading a parser meets in the raw DOM is not the h1 at all. Often a hero label or a 'featured' tag above the fold uses a heading tag before the page's real topic statement does. Only three of those nine pages also show up in the multiple-h1-or-skipped-level group above; the other six are otherwise clean by every other measure. It is a distinct defect, not a restatement of the same one.

This complements the site's broader AEO Adoption Scan, which looked at schema and llms.txt adoption across a similar set of ranking pages. Both scans point the same direction: individually small technical fixes are easy to skip and rarely what separates the top result from the tenth.

Does a clean heading hierarchy help you rank?

Hygiene, not leverage, is the honest read of that number. Thirty-two of the 41 pages already do the thing every guide to this topic tells you to do, and until this scan I could only guess at how many. If almost four in five competitors already clear that bar, a clean outline cannot be what separates the page in position 2 from the page in position 11. Too many pages share it for that to be the explanation. It looks far more like a floor you need to clear to be taken seriously than a lever you pull to climb.

The nine pages with a defect make the same point from the other side. They rank anyway. A second h1 or a skipped h2-to-h4 did not disqualify them from a competitive result page. The failure mode described above is real as a comprehension problem and unproven as a ranking penalty. Both things can be true at once: a chunker may still misfile a section under the wrong parent on any of these nine pages, and that page can still rank, because ranking and chunk-level comprehension are not the same signal.

Three limits narrow this reading rather than soften it:

  • One slice, one market, one day. The scan ran on September 9, 2026, against Google's US/English results only. It is not a trend, and it says nothing about April, or about Germany, or about a different set of queries.
  • Raw HTML, no rendering. curl does not execute JavaScript, so a page that assembles its headings client-side looks worse in this data than Googlebot's rendered version of the same page would look. Google's documentation describes a two-phase crawl-then-render process; this scan only reproduces the first phase.
  • Prevalence, not correlation. The scan measured how many ranking pages have a clean outline, not whether the clean ones outrank the defective ones. Answering that second question would need position data attached to all 41 rows, which this dataset does not have.

What does a correct heading structure look like?

The rule is short: one H1 per page, no skipping levels, going back up is fine, jumping forward is not.

Here's what a correct structure looks like for a typical product page:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Automation platform for engineering teams</title>
</head>
<body>
  <header>
    <a href="/" aria-label="Home">
      <img src="/logo.svg" alt="Company logo">
    </a>
  </header>
 
  <main>
    <article>
      <h1>Automation platform for engineering teams</h1>
 
      <section aria-labelledby="features">
        <h2 id="features">Features</h2>
 
        <h3>Speed</h3>
        <p>Orchestrates tasks in 200 ms on average.</p>
 
        <h3>Security</h3>
        <p>SOC 2 Type II, end-to-end encryption.</p>
 
        <h4>Audit logs</h4>
        <p>Export to SIEM via webhook or S3.</p>
      </section>
 
      <section aria-labelledby="pricing">
        <h2 id="pricing">Pricing</h2>
        <h3>Starter plan</h3>
        <p>For small teams up to 5 people.</p>
 
        <h3>Team plan</h3>
        <p>For growing businesses without limits.</p>
      </section>
    </article>
  </main>
</body>
</html>

Three details to note:

  • The logo in <header> is not an H1. It's a link with aria-label. The logo repeats on every page; it doesn't describe the topic of a specific document.
  • The H1 lives in <main> and names the page exactly. Not "Welcome!", not "We are." A concrete topic the model can index.
  • The H4 is logically nested inside the H3 "Security," not flying solo. You did not skip a level, because "Audit logs" is a sub-point of "Security." Put that H4 directly under H2 "Features" instead and nothing invents the missing H3: the chunk simply gets filed under "Features," two levels from where it belongs.

A note on the HTML5 spec. Formally, HTML5 allows multiple H1 tags inside sectioning content such as <article>, <section>, <nav>, and <aside>, each supposedly with a "local" level. No real browser or screen reader ever implemented the outline algorithm for this case, and WHATWG removed the algorithm from the HTML Standard in July 2022. One H1 per document, period. The spec be damned.

When should a heading be visually hidden?

There's text users don't need a visible heading for: primary navigation, search forms, sidebars with filters, footers. A designer says, "It's clear from context." The machine does not agree. To a crawler-agent, an unnamed <nav> is just a list of links without context.

The right compromise is an sr-only (screen-reader only) heading. Present in the DOM, present in the accessibility tree, read by LLM agents and screen readers, but invisible visually.

<nav aria-label="Main navigation">
  <h2 class="sr-only">Main navigation</h2>
  <ul>
    <li><a href="/products">Products</a></li>
    <li><a href="/pricing">Pricing</a></li>
    <li><a href="/docs">Documentation</a></li>
  </ul>
</nav>
 
<aside aria-label="Catalog filters">
  <h2 class="sr-only">Catalog filters</h2>
  <form action="/search" method="GET">
    <label for="category">Category</label>
    <select id="category" name="category">
      <option value="all">All</option>
      <option value="tools">Tools</option>
    </select>
  </form>
</aside>

The canonical sr-only implementation. Its CSS properties match Bootstrap's .visually-hidden utility exactly:

sr-only.css
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

Tailwind's .sr-only keeps every other line but swaps clip: rect(0, 0, 0, 0) for clip-path: inset(50%), since MDN marks clip deprecated in favor of clip-path. Both clip the element's rendered box to nothing while it stays in the DOM and the accessibility tree.

What you must not do here:

  • display: none: removes the node from the accessibility tree. Invisible to both agents and screen readers. The heading effectively does not exist.
  • visibility: hidden: same problem, plus it leaves an empty space in the layout. Worst option.
  • opacity: 0: stays in the tree but gets read by focus and breaks keyboard tab navigation.

Neither clip nor clip-path is on that list, and that is the whole point of both. The element is painted outside the visible area while staying fully present in the DOM and in the accessibility tree. Visually gone, machine-readable intact. That is the behaviour both frameworks are after, even if they no longer reach for the same property to get there.

An sr-only heading is not "an SEO crutch." It's a declaration that a logically separate text section with a concrete topic starts here. That's exactly what the ranker reads, and what the agent uses when planning actions on the page.

Headings name a section for a machine; JSON-LD types the things inside it. The two answer different questions: where does this passage belong, versus what kind of object is this. A page that gets the outline right and the markup wrong still hands a retriever an untyped blob.

How do you check your own outline?

Open any page, go to the Console tab in DevTools, and run this snippet. It uses modern standards, no jQuery or other deprecated libraries.

audit-headings.js
const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
const headingsData = Array.from(headings).map((heading, index) => {
  return {
    order: index,
    level: heading.tagName,
    text: heading.innerText.trim().slice(0, 60),
    hidden:
      heading.classList.contains('sr-only') ||
      heading.offsetParent === null,
  };
});
 
console.table(headingsData);

Look at the level column in the resulting table. Answer three questions:

  1. Is there exactly one H1 on the page?
  2. Is the descent sequential, with no skipped levels?
  3. Is every H2/H3 you don't see visually explicitly marked with the sr-only class, and not hidden via display: none?

If the answer to any of these is "no," you have two paths. The first, and correct one, is to rewrite the DOM so the structure mirrors the logic of the content. The second, a quick band-aid, is to add sr-only headings where a semantic bridge between levels is needed, until proper refactoring catches up with the backlog.

What each level signals to a chunker

A retrieval pipeline does not read your CSS. The heading level is the only statement you make about where one idea stops and the next begins.

Level What it claims What breaks when it is misused
h1 This is what the page is about Two h1s and the page has no single subject to match a query against
h2 A top-level section, quotable alone Skipping to h3 merges the section into the one above it
h3 A subdivision of the h2 above An h3 with no parent h2 becomes an orphan chunk with no context
h4+ Detail inside a subdivision Rarely retrieved alone; safe to nest, pointless to skip to
styled div Nothing The section is invisible to the chunker; its text merges into the previous one

That last row is the expensive one. A <div class="text-2xl font-bold"> looks exactly like a heading to a reader and exactly like a paragraph to a parser, so the section it introduces gets glued onto whatever came before it.

FAQ

Can a page have more than one h1?

The HTML spec permits it inside sectioning elements, and every practical consumer, including search engines, screen readers, and retrieval chunkers, treats one h1 as the page's subject. Multiple h1s do not throw an error. They just leave the page without a single answer to what this is about, which is exactly the question the retrieval step is asking.

Does skipping from h2 to h4 actually matter?

Yes, and not for style-guide reasons. A chunker builds the parent-child tree from the levels alone, so an h4 after an h2 either gets attached to the wrong parent or starts a section with no context. Screen readers announce the same broken tree. The visual result is identical, which is why it survives review.

What is a visually hidden heading for?

It exists for content that genuinely has a section, but the design has no room to label it: a sidebar, a card grid, a filter panel. An sr-only heading gives the parser and the screen reader the structure they need without changing the layout. It is a bridge, not a licence to skip levels everywhere else.

Is the outline algorithm still a thing?

No, and relying on it is a common source of broken structure. The old HTML5 document-outline algorithm, where nesting <section> elements re-based heading levels, was never implemented by any browser or assistive technology and has been removed from the spec. Heading levels mean exactly the number you wrote.

How do I audit the outline quickly?

Print the headings in order and read them as a table of contents. If it does not make sense as a list, it will not make sense as a chunk tree. The AEO (Answer Engine Optimization) Checker extension reports the heading hierarchy for the page you are on, including any level that got skipped.

What are the benefits of semantic HTML for AI crawlers?

Semantic HTML gives a crawler a topic declaration in the h1, a parent-child map in h2 through h6, landmark regions such as nav, main, and article for agents that navigate rather than read, and a stable target for the accessibility tree. The parent-child map matters most for AI crawlers specifically, since without it a chunker cannot tell which section a paragraph belongs to.

What are some examples of semantic HTML tags?

The core landmark set is header, nav, main, article, section, aside, and footer. Add figure with figcaption for captioned media and time for machine-readable dates. The heading elements h1 through h6 are semantic too: each one declares a level in the outline, not a font size.

Can ChatGPT crawl JavaScript-rendered headings?

Not reliably, per independent testing rather than OpenAI's own documentation. Vercel's traffic analysis found no JavaScript execution from GPTBot, OAI-SearchBot, or ChatGPT-User, so a heading a script inserts after load stays invisible to them even though a human visitor sees it fine. The JavaScript Trap covers the rendering gap in full.

Does Googlebot see my headings the same way this scan did?

Not exactly, and that gap is a real limit of this scan. Googlebot renders JavaScript before it indexes a page, while this scan fetched raw HTML with curl and never ran a script. A page that builds its headings client-side can look broken in this data and still look fine to Googlebot. Treat these numbers as what a non-rendering AI crawler sees, not as a claim about what Google sees.

Sources

Part of the Answer Engine Optimization cluster, with the full reading order in dependency sequence, plus a glossary of every term used across these articles.


Did your structure audit reveal problems on your site?

Follow me on LinkedIn to keep up with the technical side of optimization for AI. If your project needs architectural review, a clean semantic migration, or an AEO-aware Next.js setup, get in touch directly.

Get new posts

One article every couple of weeks, on AI search and the code behind it. Confirm by email; unsubscribe in one click.