Building in Public#building-in-public

How to Fix Duplicate H1 Tags in Next.js Markdown Posts

By Mohamed Abdi Guled

Every blog post on ShabelleHub had two H1 tags in the rendered HTML — invisible, but indexed by Google.

Fixing Duplicate H1 Tags in Markdown Blog Posts

Every blog post on ShabelleHub had two H1 tags. Not visually — you could not see both of them at the same time — but in the HTML that Google indexed, there were always two <h1> elements on every blog post page.

This is a heading hierarchy bug that is easy to miss because it does not break anything visually. It only shows up when you inspect the rendered HTML or run an accessibility audit. And it matters for both SEO and accessibility.


How It Happened

The blog post page in pages/blog/[slug].js rendered the post title as an H1:

// pages/blog/[slug].js
<h1 style={{ fontFamily: 'Space Grotesk, sans-serif', fontSize: 'clamp(22px,5vw,30px)', fontWeight: 800 }}>
  {post.title}
</h1>

Then the post body was passed through a renderMarkdown function that converted markdown to HTML. The function had a rule for lines starting with # :

function renderMarkdown(md) {
  if (!md) return '';
  return md
    .split('\n')
    .map(line => {
      if (/^# (.+)$/.test(line))
        return `<h1 style="...">${escapeHtml(line.replace(/^# /, ''))}</h1>`;
      if (/^## (.+)$/.test(line))
        return `<h2 style="...">${escapeHtml(line.replace(/^## /, ''))}</h2>`;
      // ...
    })
    .join('\n');
}

Every blog post content started with a # Title line — the same title that was already rendered as the page H1. The renderMarkdown function dutifully converted it to a second <h1> tag inside the post body.

Result: two H1 elements on every page. One from the page template. One from the markdown content.


Why Duplicate H1 Tags Are a Problem

For SEO: A page's H1 is its primary heading signal — the clearest indication to Google of what the page is about. When a page has two H1 elements, that signal is diluted. Google has stated that multiple H1 tags are not a penalty in themselves, but they indicate a heading hierarchy that was not carefully considered, which correlates with lower content quality.

For accessibility: Screen readers use heading structure to help users navigate pages. The heading hierarchy is expected to flow: one H1, then H2 sections beneath it, then H3 subsections beneath those. Two H1 elements at the same level break this navigation model. A user navigating by headings would encounter the post title twice — once from the template and once from the markdown body — with no indication that the second occurrence is the start of the content rather than a new top-level section.

For WCAG compliance: WCAG 2.1 success criterion 1.3.1 (Info and Relationships) requires that the structure conveyed visually is also conveyed programmatically. The visual presentation showed one title. The HTML had two H1 elements. These were inconsistent.


The Fix

Two approaches were possible:

Option A: Strip the leading # Title line from every post's markdown content before rendering, since the page template already renders the title.

Option B: Change the renderMarkdown function so that # in the post body produces <h2> instead of <h1>, preserving the ## → h2 and ### → h3 mapping.

Option A is cleaner because it removes redundant data. If the page renders the title from post.title, the post body does not need to repeat it. We implemented a filter that removes the first # Title line from the markdown before mapping:

function renderMarkdown(md) {
  if (!md) return '';
  return md
    .split('\n')
    // Remove the leading '# Title' line — the page already renders
    // post.title as the single <h1>. A second <h1> from the markdown
    // body breaks heading hierarchy on every blog post page.
    .filter((line, idx, arr) => {
      const firstContentIdx = arr.findIndex(l => l.trim() !== '');
      return !(idx === firstContentIdx && /^# .+$/.test(line));
    })
    .map(line => {
      if (/^# (.+)$/.test(line))
        return `<h2 style="...">${escapeHtml(line.replace(/^# /, ''))}</h2>`;
      if (/^## (.+)$/.test(line))
        return `<h2 style="...">${escapeHtml(line.replace(/^## /, ''))}</h2>`;
      if (/^### (.+)$/.test(line))
        return `<h3 style="...">${escapeHtml(line.replace(/^### /, ''))}</h3>`;
      // ...
    })
    .join('\n');
}

The filter finds the first non-empty line in the markdown. If that line is a # Title pattern, it is removed before the map runs. Every other line is processed normally.

The # rule in the map is kept as a safety fallback — if a # heading appears mid-content (not as the first line), it now produces an <h2> rather than an <h1>, which is the correct behaviour for a sub-heading inside a post that already has a page-level H1.


Verifying the Fix

After deploying, we verified the heading structure using the browser's accessibility tree:

    • Open DevTools → Accessibility tab
    • Navigate to the blog post page
    • Check the heading structure panel

Before the fix, the structure looked like:

h1: "Fixing Duplicate H1 in Markdown Blog Posts"   ← from page template
h1: "Fixing Duplicate H1 in Markdown Blog Posts"   ← from markdown body
h2: "How It Happened"
h2: "Why Duplicate H1 Tags Are a Problem"

After the fix:

h1: "Fixing Duplicate H1 in Markdown Blog Posts"   ← from page template only
h2: "How It Happened"
h2: "Why Duplicate H1 Tags Are a Problem"

One H1. Correct hierarchy. Every blog post now has a clean heading structure from the first render.


The Broader Pattern

This bug exists in a large number of Next.js blogs and markdown-based sites. The pattern that produces it is:

    • A page template renders the post title as an H1
    • The post content is stored with a # Title line at the top
    • The markdown renderer converts # to <h1>

If you use a markdown renderer in Next.js and your posts start with # Title, check whether your page template is also rendering the title as an H1. If it is, you have this bug.

The fix is always some version of the same decision: either the template renders the title (and the markdown body does not repeat it), or the markdown body contains the title (and the template does not add its own). Both approaches work. Having both simultaneously does not.


The Rule

A page should have exactly one H1 element. That H1 should describe the primary topic of the page. Everything beneath it should use H2, H3, and lower — in order, without skipping levels.

This is not a stylistic preference. It is the heading structure that search engines use to understand page content and that screen readers use to help users navigate. Getting it right is one of the lowest-effort, highest-impact accessibility and SEO improvements available.


*This article is part of the ShabelleHub Building in Public series.*

*Next: Dynamic Footer Links in Next.js*

📬 Get the latest AI tool reviews

Expert picks and comparisons, weekly. No spam.

← Back to Blog