# An h1 inside a header element counted as no h1 at all

> The raw HTML of five pages this scanner captured on 15 July 2026 holds 245 heading elements and our extractor recorded 166, because it discards every heading inside nav, header, footer or aside. On two of those pages the rule turned a real h1 into a reported count of zero.

- Canonical page: https://lantad.co/blog/an-h1-inside-a-header-counted-as-none
- This file: https://lantad.co/blog/an-h1-inside-a-header-counted-as-none.md
- Last substantive update: 2026-08-17

## Key facts

- **Published:** 2026-08-17
- **Category:** Findings
- **Author:** Lantad
- **Length:** 3410 words
- **Takeaway 1:** The raw HTTP responses of five real pages stored as golden fixtures in this repository, captured on 15 July 2026, contain 245 h1 to h6 elements, and Lantad's extractor recorded 166 of them.
- **Takeaway 2:** 80 of those 245 headings sit inside a nav, header, footer or aside element, and core/src/extract.ts discards every one of them before the heading list is built.
- **Takeaway 3:** The English Wikipedia article on web crawlers carries exactly one h1 in its markup, at main > header > h1, and Lantad reported h1Count as 0 and emitted the defect text Found 0 H1 elements in the initial HTML.
- **Takeaway 4:** MDN's header element reference, last modified 24 April 2026, states that a header nested within main usually contains the surrounding section's heading, which is the exact placement this extractor throws away.
- **Takeaway 5:** The headingCoverage check passed on all five pages because it compares a count against words divided by 300 and never reads a heading's level, so three level skips on gymshark.com cost nothing.

## Summary

A heading is the cheapest structural signal a page can carry. It is one tag, it needs no vocabulary and no validator, and it tells any client walking the document that a new topic starts here. That is why headings are the first thing a text extractor reaches for when it has to group a page into parts, and why two of the seven checks behind this scanner's structure score are about them. What our own extractor actually does with a heading turned out to be less obvious than either check implies.

So we went back to the captures this repository keeps as golden fixtures and counted, because the question is answerable from data already sitting on disk rather than from an opinion about semantic markup. [Those captures are five real pages](https://lantad.co/blog/alt-text-on-466-captured-images) fetched and stored on 15 July 2026, kept fixed so that a change in scoring has something stable to be measured against. They were captured to exercise [the scoring engine as a whole](https://lantad.co/methodology), not to study headings, and that is the useful part: nobody chose these pages for how their heading markup behaves. Running the extractor in core/src/extract.ts over the raw HTTP response of each one, then comparing what it recorded against what the markup contains, produced a gap wide enough to change a check's answer on two pages out of five. The reason it happens is a rule that is defensible on its own terms and wrong at one specific job, which is the kind of thing worth writing down before somebody reads a grade from it and concludes something false about [whether an AI crawler can read their page](https://lantad.co/glossary/ai-crawler).

## Where 79 of the 245 headings went

The extractor walks the document with htmlparser2 and sorts everything it meets into three kinds. Anything inside script, style, noscript, template, svg or iframe is dropped outright, because that content is either not text or is routed elsewhere for JSON harvesting. Anything inside main or article is main content, as is any h1 to h6, p or li anywhere in the page. Anything inside nav, header, footer or aside is boilerplate, and the comment at the top of the file states the precedence in three words: boilerplate wins. A heading classified as boilerplate is never added to the heading list and never counted toward h1Count.

Across the five raw responses that rule removes a lot. The markup holds 245 h1 to h6 elements. 80 of them sit somewhere inside a nav, header, footer or aside, and none of the 245 sits inside a dropped tag. The distribution is uneven in a way that tracks how each site is built: webflow.com loses 32 of its 39 headings, gymshark.com loses 41 of 137, astro.build loses 4 of 31, the Wikipedia article loses 2 of 28 and the developer.mozilla.org landing page loses 1 of 10. A site whose navigation is a large linked menu of category headings is penalised most, and that is the rule working as designed rather than failing: a mega menu repeated on every page is not what the page is about, and letting it into the heading list would make every page on the site look identically well structured.

Subtracting leaves 165, and the extractor recorded 166. The extra entry is worth naming because it is not a rounding artefact. Headings are recorded when a text block is flushed, and the flush happens at the boundary of any block-level tag, br included. The astro.build page carries an h2 reading "Start building with" followed by a br and then "Astro today", so one h2 element produced two flushes and was recorded as two headings, "Start building with" and "Astro today". Nothing about that is malicious and no site owner would notice, but it means the heading count that feeds a check is a count of flushed heading blocks rather than a count of heading elements, and a line break inside a heading raises it.

This is the same class of thing as [the text that our extractor drops inside a shadow root](https://lantad.co/blog/shadow-dom-text-and-the-extractor) and the reason [we compare the raw response against the rendered DOM](https://lantad.co/glossary/prose-parity) rather than trusting either alone. It is also why this post is about the raw HTTP response: rendering barely moves these numbers. Across the five pages the rendered DOM produced the same heading counts as the raw response everywhere except gymshark.com, which went from 96 to 98. Headings are not a JavaScript story, which matches [the finding that rendering added no new crawl paths on the same captures](https://lantad.co/blog/javascript-added-no-new-crawl-paths).

## Why Wikipedia's article title reported as zero h1 elements

Two of the five pages failed the exactlyOneH1 check, and on both of them an h1 is present in the markup. The interesting one is the Wikipedia article, because it carries exactly one h1 and should have passed.

Its ancestor chain, read off the stored capture, is html > body > div > div > div > main > header > h1, and the element itself is the familiar one: id="firstHeading", class="firstHeading mw-first-heading". It is inside main, which is the strongest main content signal in the document, and it is wrapped in a header, which the extractor treats as boilerplate. Boilerplate wins, so the heading is discarded, h1Count is 0, and the verdict layer emits a defect whose text reads "Found 0 H1 elements in the initial HTML." That sentence is false about the initial HTML. There is one h1 in it.

The webflow.com case is less clean and more common. Two h1 elements sit at main > div > div > aside > header > a run of divs > h1, reading "Make your website a growth engine" and "Make websites that drive results", which looks like a live copy test with both variants shipped in the markup. Two h1s fail exactlyOneH1 on their own merits, so the check reaches the right verdict, but it reaches it for the wrong reason and reports the wrong evidence: the defect says 0 elements were found where the page has 2. A site owner acting on that defect would go looking for a missing heading rather than a duplicated one.

What makes this uncomfortable rather than merely untidy is that the markup is not unusual. [MDN's reference for the header element](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/header), last modified 24 April 2026, states that when a header is nested within sectioning content or main it loses its landmark status and represents introductory content for the surrounding section, and that it usually contains the surrounding section's heading, an h1 to h6 element, and an optional subheading. The specification-level description of a header inside main is the container you put the section heading in. So the placement this extractor discards is not an edge case a careless template produced. It is the documented normal case, and it is what a modern component framework tends to emit, which is worth knowing if you build on [Next.js](https://lantad.co/fix/nextjs) or any stack where a page header is its own component.

None of this changes what a crawler sees. The h1 is in the bytes either way, and no claim is being made here about how GPTBot or any other crawler weights it, because that is a property of code we cannot read. What it changes is what this scanner says about the page, and a measurement product that reports a false count of a thing a reader can verify in five seconds has a problem with the measurement rather than with the page. It is the same shape of error as the scoring gap [we found reading our own fixtures against the access weight](https://lantad.co/blog/a-robots-txt-that-blocks-every-citation-crawler-still-grades-b), and it belongs in the record for the same reason. If you want to know what your own [AI visibility](https://lantad.co/glossary/ai-visibility) score is actually counting, the useful question is always which elements the tool threw away before it counted.

## What the h1 check is worth in the score

The size of the error matters as much as its existence, so here is the arithmetic from the constants rather than from memory. Structure is 15 of the 100 composite points, behind prose parity at 50 and access at 25. Inside structure, STRUCTURE_CHECK_WEIGHTS in core/src/config.ts gives six checks a weight of 1 each and llmsTxtPresent a weight of 0.5, for a total of 6.5. So exactlyOneH1 carries one part in 6.5 of 15 points, which is a little over 2.3 points out of 100.

That is small, and saying so is part of reporting it honestly. A page does not move a grade band on this check alone. But two things stop it being negligible. The first is that the defect text is published to the reader as evidence, and evidence that is wrong is worse than evidence that is missing, because the reader has no way to tell the difference and will go and edit something. The second is that the same discarded heading list feeds the other heading check, so one classification rule is behind both.

There is a fair argument for the rule as written. Excluding navigation and footer headings from a structure judgement is correct: a site whose only headings are its menu has no document structure, and counting them would hand it the same score as a page with a genuine outline. The gap is narrower than the rule. It is that boilerplate wins unconditionally, so a header inside main is treated identically to a header that is the site banner, when the two are different elements doing different jobs and the markup already distinguishes them by where they sit. Nothing here proposes a fix in passing, because [we do not re-baseline a score without saying so](https://lantad.co/blog/why-we-withhold-a-grade), and a change to this rule moves stored golden scores.

Publishing the inconvenient version of your own numbers is the only way a measurement claim stays worth anything, and it is the standard the rest of this site's writing on [generative engine optimisation](https://lantad.co/glossary/geo) is held to. The figure to take away is not that a scanner has a bug. It is that a structure score is a set of authored rules about which parts of your document count, that those rules are visible in this repository, and that the honest thing to do with them is check them against real pages and print what comes back.

## Heading coverage counts headings and never reads their level

The second heading check is headingCoverage, and it passed on all five pages. Its rule is one line of arithmetic: divide the main content word count by wordsPerHeading, which is 300 in core/src/config.ts, take the floor, and pass if the heading list is at least that long. A page with no words passes trivially, which is deliberate, because a short page has nothing to break up.

Two of the five pages passed with almost nothing to spare. webflow.com holds 2,383 words of main content, so it needs 7 headings, and after the boilerplate rule removed 32 of its 39 it has exactly 7. The margin is zero. The Wikipedia article holds 7,576 words, needs 25 and has 26. Nothing is wrong with a ratio test as such, but a check that a page passes by one element is not evidence about that page, and reading it as a green light would be reading precision the number does not have.

The larger point is what the check does not look at. HeadingInfo, the shape the extractor records, carries a level and a text. computeStructure reads the length of the list and nothing else; the level survives only in h1Count, which was set earlier during the walk. So the ordering of a document's headings is recorded, carried through the pipeline, and then discarded unread. gymshark.com is where that shows: 96 recorded headings, 60 of them h3, and three places where the level jumps by more than one, twice from h2 straight to h5 and once from h3 to h5. Its first recorded heading is an h2, its h1 appears later in the document order, and it passes headingCoverage comfortably on volume.

Whether that matters is a question the sources answer more carefully than the marketing around structured content usually does. [MDN's heading elements reference](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/Heading_Elements), last modified 24 April 2026, states plainly that you should not skip heading levels, always starting from h1 and following with h2, and that a page should generally have a single h1 describing the content of the page. [The W3C Web Accessibility Initiative tutorial on headings](https://www.w3.org/WAI/tutorials/page-structure/headings/), last updated 4 May 2017, puts it as a nesting rule and gives the concrete instruction that an h2 should not be followed directly by an h4, while allowing a rank to be skipped when a subsection closes rather than opens. Both are written about assistive technology and document semantics. Neither is a claim about retrieval, and this post does not turn them into one.

What can be said without inventing anything is narrower and still useful. The hierarchy is in your markup, it is the thing a chunker would use if it used anything, it costs nothing to get right, and Lantad does not currently score it. If you are on a hosted platform where the theme decides your heading levels, such as [a Shopify storefront](https://lantad.co/fix/shopify), the levels are a template decision rather than an editorial one, which is exactly the case where nobody checks. That is worth pairing with [the machine-readable claims in your structured data](https://lantad.co/glossary/structured-data), because those two are the parts of a page whose correctness a person never sees.

## What to check on your own page

None of the above supports a percentage about the web. Five pages captured on one afternoon, chosen months earlier as scoring fixtures, describe five pages. What they do support is a short list of things that are cheap to verify on a page you own, and each of them is a fact about your markup rather than a guess about a model.

Start by finding your h1 in the served HTML rather than in the browser inspector, because the two can differ and the fetched bytes are what a crawler that does not run JavaScript receives. View source, search for the tag, and confirm there is exactly one and that it says what the page is about. Then look at what wraps it. If it sits inside a header, a nav or an aside, it is inside a container that at least one text extractor treats as page furniture, and you now know of one that does because it is documented here. That does not make the markup wrong. It makes the outcome dependent on somebody else's classification rule, and the fix, if you want one, is usually to place the page heading in the main content flow rather than in a header component that also carries the breadcrumb and the share buttons.

Second, read your heading levels in order and check that they descend by one. This takes a minute and is the single most neglected thing on the list, because a level skip is invisible to everyone using the page normally and shows up only when something walks the document. Third, count them against your word count. One heading per 300 words is the threshold this scanner uses, which is a chosen setting and not a measured optimum, so treat it as a floor to clear rather than a target to hit.

If you would rather see the fetched document than read source, [our tool for viewing a page as a crawler receives it](https://lantad.co/tools/what-gptbot-sees) shows the raw response, and every page on this site is also served as markdown so you can compare a machine-readable version against the rendered one at [the markdown index](https://lantad.co/md). For a single-page application built with [React](https://lantad.co/fix/react), the ordering question usually resolves at the component level rather than the page level, since headings arrive from different components that were each written to look right in isolation. And if the reason you are here is that you want a specific engine to quote you, the honest sequence is still access first, text second, structure third, which is the order [our guide to getting cited in ChatGPT](https://lantad.co/how-to-get-cited/chatgpt) works through. A perfect heading outline on a page a crawler is not allowed to fetch is worth nothing at all.

## Questions and answers

**Does an h1 inside a header element hurt my AI visibility?**

Nothing measured here shows that it does. What it does is change what this scanner reports: core/src/extract.ts classifies nav, header, footer and aside as boilerplate and discards headings inside them, so an h1 in a header is recorded as no h1. On the English Wikipedia article captured on 15 July 2026 that produced a defect reading Found 0 H1 elements in the initial HTML on a page whose markup contains one. How any particular crawler weights an h1 is a property of code we cannot read, and no claim is made about it here.

**How many headings should a page have?**

Lantad's headingCoverage check asks for at least one heading per 300 words of main content, taken from wordsPerHeading in core/src/config.ts. That is a chosen setting rather than a measured optimum, and it is a floor rather than a target. All five captures of 15 July 2026 passed it, two of them by a margin of zero and one, so passing it is weak evidence about a page.

**Does Lantad score heading hierarchy?**

No. The extractor records a level for every heading it keeps, and computeStructure in core/src/verdict.ts reads only the length of the list plus the separately maintained h1Count. Three level skips on the gymshark.com capture, twice from h2 to h5 and once from h3 to h5, cost that page nothing in the score. MDN's heading elements reference, last modified 24 April 2026, says not to skip levels, and the W3C Web Accessibility Initiative tutorial on headings gives the same rule for assistive technology, but neither is a retrieval claim.

**Why does the heading count not match the number of heading tags?**

Two reasons, both measurable on these captures. The boilerplate rule removed 80 of the 245 h1 to h6 elements in the five raw responses. Separately, headings are recorded when a text block is flushed and a br flushes a block, so an h2 on astro.build reading Start building with, a line break, then Astro today was recorded as two headings from one element. That is why 245 minus 80 is 165 and the recorded total is 166.

---

Lantad measures whether AI crawlers can actually read a page: it fetches as a non-rendering
crawler, renders as a browser, and reports the gap. Free scan, one URL, no signup.

Method and weights: https://lantad.co/methodology | All pages as markdown: https://lantad.co/md | Crawler policy: https://lantad.co/bot
