Fixing Heading Order Problems in Markdown Content

Screen reader users navigate long pages by headings more than by anything else. NVDA's H key jumps to the next heading, the numbers 1 to 6 jump by level, and the elements list shows the page as an outline. That outline is only as good as the heading levels in the content. When a writer chooses #### because the H2 looked too large, or starts a file with # Title under a layout that already renders the title, the outline lies: a skipped level suggests a missing section, and two H1s suggest two pages.

On the reference site from Accessibility for Static Sites, the first content lint found 173 skipped levels and 22 pages with two H1s across 1,140 pages. This guide fixes them in bulk with a codemod, adds a lint that blocks new ones, and covers the template and component cases a Markdown lint cannot see.

Prerequisites

  • A Markdown pipeline that accepts remark or markdown-it plugins, or Hugo render hooks.
  • Write access to the content repository and a CI step for pull requests.
  • Agreement with writers on the convention: the layout renders the H1; Markdown starts at H2.

What the Audit Found

A remark script listed every heading with its level, file and line, then flagged three patterns:

PatternExampleCount
Skipped level going deeperH2 followed by H4173
Second H1 from Markdown# Getting started under a layout H122
Empty or symbol-only heading## or ### ---9
Heading used for a callout#### Note before a paragraph61 (subset of the 173)
Bold paragraph used as a heading**Configuration** on its own line118

The last row is the opposite problem: text that looks like a heading but is not one, so it is invisible in the outline. It is not a WCAG failure in every case, but on reference pages where the bold lines introduced sections, they were converted to real headings.

Number of heading problems found by pattern in the first audit Vertical bars. Bold paragraphs used as headings 118. Skipped levels 173, of which 61 were headings used as callouts. Second H1 from Markdown 22. Empty headings 9. Heading problems by pattern 173 Skipped level 118 Bold as heading 61 Heading as callout 22 Second H1 9 Empty heading
1,140 pages scanned; bold-as-heading lines were reviewed by hand before conversion.

Step 1: Remove the Duplicate H1s

Starlight, VitePress and Docusaurus render the frontmatter title as the H1. Twenty-two files also started with # Title, a habit carried over from a previous generator. A one-line check finds them and a small script removes the first line when it matches the frontmatter title:

grep -rlE '^# ' src/content/docs | while read f; do
  title=$(sed -n 's/^title: *//p' "$f" | head -1 | tr -d "'\"")
  first=$(grep -m1 -E '^# ' "$f" | sed 's/^# //')
  [ "$title" = "$first" ] && sed -i "0,/^# /{/^# /d}" "$f"
done

Where the Markdown H1 differed from the title, a writer chose which to keep. Three of those pages had a better H1 in the Markdown than in the frontmatter, and the frontmatter was updated instead.

Step 2: Repair Skipped Levels With a Codemod

Most skipped levels follow a pattern: an H2 section with H4 subsections, where H3 was skipped because it looked too big. Promoting each heading to at most one level below its parent fixes the outline without changing meaning:

// scripts/fix-heading-levels.mjs
import { remark } from 'remark';
import { visit } from 'unist-util-visit';
import remarkFrontmatter from 'remark-frontmatter';

export function fixLevels() {
  return (tree) => {
    let prev = 1; // layout renders the H1
    visit(tree, 'heading', (node) => {
      if (node.depth > prev + 1) node.depth = prev + 1;
      prev = node.depth;
    });
  };
}
// run over every file with remark().use(remarkFrontmatter).use(fixLevels)

The codemod changed 173 headings in 211 files' worth of diffs. The 61 callout headings were then converted by hand to the site's :::note aside syntax, because a callout is not a section and should not be in the outline at all. Reviewing the diff took one person about three hours.

Number of skipped heading levels per section of the docs before and after Paired bars by docs section. Guides went from 64 skipped levels to 0. API reference from 51 to 0. Tutorials from 38 to 0. Concepts from 20 to 0. Outline depth jumps before and after the codemod Before After Guides 64 0 API reference 51 0 Tutorials 38 0 Concepts 20 0
Counted by the lint over every file in each section; the after column is zero by construction and was confirmed with the HTML check.

Step 3: Make Size a Styling Decision

Writers picked deeper levels because the default H3 was 1.5 rem and looked heavy inside short sections. The type scale was adjusted so H3 is 1.2 rem and H4 1.05 rem with a heavier weight, which removed the visual reason to skip. For the rare heading that genuinely needs a smaller look, Starlight and Astro Markdown support {.class} attributes through a remark plugin, and Hugo supports {.class} on headings natively with markup.goldmark.parser.attribute.title = true.

Step 4: Lint Every Pull Request

The lint runs the same depth rule and two others, failing on any violation with a message that names the fix:

// plugins/remark-heading-lint.mjs
export default function headingLint() {
  return (tree, file) => {
    let prev = 1;
    visit(tree, 'heading', (node) => {
      const text = toString(node).trim();
      if (node.depth === 1) file.fail('Do not use # in Markdown; the layout renders the page title as H1', node);
      if (!text) file.fail('Empty heading', node);
      if (node.depth > prev + 1)
        file.fail(`Heading jumps from H${prev} to H${node.depth}; use H${prev + 1} and style it with CSS if it looks too large`, node);
      prev = node.depth;
    });
  };
}

file.fail throws, so the build stops with the file and line. For Eleventy, the same logic goes in a markdown-it core rule that walks heading_open tokens; for Hugo, a heading render hook can track the previous level in a page scratch variable and call errorf.

Step 5: Check the Rendered HTML Too

Components can add headings the Markdown lint never sees. The reference site had two: a "Related" component that rendered an H4 at the end of every page, after an H2, and an API parameter table component that used H5 for each parameter name. A post-build check walks every page's headings in document order inside <main> and applies the same rule:

const hs = [...main.querySelectorAll('h1,h2,h3,h4,h5,h6')].map(h => +h.tagName[1]);
hs.reduce((prev, d, i) => { if (d > prev + 1) problems.push(`${file}: H${prev} → H${d} at heading ${i}`); return d; }, 0);

The Related component became an H2, and the parameter names became <dt> elements in a definition list, which is what they were. axe's heading-order rule covers the same ground on the pages it scans, but this check covers all 1,140 pages in two seconds.

How Markdown and HTML heading checks cover different sources Four steps. Markdown headings written by authors are checked by the remark lint during the build. Component headings rendered by layouts and MDX components are not visible to the lint. The built HTML is checked for heading order inside main on every page. Both feed a single CI result that fails on any jump. Two checks, two sources of headings Markdown headings remark lint with line numbers Component headings invisible to the lint HTML check all pages, inside main One CI result fail on any jump
The Markdown lint gives writers a line number; the HTML check catches component headings.

Measured Impact

MeasureBeforeAfterTool
Skipped heading levels1730remark lint
Pages with two H1s220HTML check
axe heading-order (60 pages)47 elements0axe-core 4.10
Bold lines converted to headings—118Manual review
Lint + HTML check time—0.3 s + 2 sCI logs
PRs blocked in 8 weeks—16, all fixed in the PRCI history

A screen reader user who tested the API section before and after described the "after" outline as the first time the API reference could be navigated "by its headings instead of by searching for words".

Pitfalls & Rollback

  • Fixing the outline by changing CSS only. Size and level are separate; fix both.
  • Turning callouts into deeper headings. Use an aside component instead.
  • Linting Markdown but not components. Run the HTML check too.
  • Running the codemod without review. Most changes are mechanical, but some deeper headings were meant to be sections of their own.
  • Rollback: the codemod is a single commit that can be reverted; the lint can be set to warn instead of fail with one option.

Conclusion

Heading levels are the map screen reader users navigate by, and Markdown makes them easy to choose for size instead of structure. On the reference site a codemod repaired 173 skipped levels, a two-line rule removed 22 duplicate H1s, and a lint plus an HTML check keep new problems out of both content and components.

FAQ

Is a skipped heading level a WCAG failure?

Not strictly. WCAG 1.3.1 requires that structure conveyed visually is available programmatically, and a skipped level is usually reported by axe as a best-practice issue rather than a violation. In practice it misleads screen reader users, who infer a missing section when a level is skipped, so it is worth fixing everywhere and enforcing for new content.

Should the Markdown title be an H1 if the layout already renders one?

No. Most documentation layouts render the frontmatter title as the page H1, so a Markdown file that starts with a single hash produces a second H1. Either remove the heading from the Markdown or configure the layout not to render the title, but not both. One H1 per page is the convention screen reader users rely on.

How do I make a heading smaller without changing its level?

Use CSS. Heading level is structure and size is presentation, so if a section heading looks too large, style it with a class or adjust the type scale rather than picking a deeper level. In Markdown, most generators support attribute syntax or a component for adding a class when one heading genuinely needs a different look.

Do headings inside components and MDX count?

Yes. Screen readers see the rendered HTML, so a component that renders an H4 inside a page section affects the outline just like a Markdown heading. A check on the built HTML catches these; a Markdown-only lint does not.

Filed underFixes