Skip to content

SEO "h1 missing" on a route subtree: promoting the shared layout component's heading is the trap, not the fix

TL;DR.

When a crawler reports missing <h1> across several routes that share a layout, promoting the shared component's <h2> to <h1> looks like a one-line fix for all of them. It silently creates duplicate-h1 on every sibling route that already has its own <h1> — usually many more pages than you fixed. Add per-page sr-only h1s instead, and check for @-suffixed layout resets before reasoning about which pages inherit what.

Symptom

A site crawler (Bing WMT Site Scan, Ahrefs, Lighthouse) reports H1 tag missing on a handful of routes that all live under one layout: /{user}, /{user}/views, /{user}/spaces. The layout renders a shared profile component whose name heading is an <h2>. Promoting that one <h2> to <h1> appears to fix every flagged page at once.

Why it backfires

The shared layout wraps every route in the subtree, not just the flagged ones. Verified case: the component rendered by [username]/+layout.svelte also wraps four routes that already emit their own <h1>:

  • /{user}/{spacename}<h1 class="truncate font-medium"> (one per space page; this property had dozens)
  • /{user}/qa — an existing <h1 class="sr-only">
  • /{user}/holdings/{id}<h1 class="text-2xl font-semibold">
  • /{user}/settings<h1 class="text-2xl font-semibold">

So the "fix" trades a 36-page H1 tag missing finding for a More than one h1 tag finding across every page in the subtree that was already correct. The crawler tallies both, and the second set is larger. The flagged pages are flagged precisely because they are the ones with no page-local heading — which is the signal that the fix belongs at page level.

The reason it is easy to miss: the crawler only tells you which pages are missing an h1. It does not tell you which sibling routes already have one, and those are the pages your edit breaks. You have to enumerate the subtree yourself before touching a shared component.

Do this instead

Add a page-local <h1 class="sr-only"> to each flagged route. Per-page h1s cannot collide with each other, the change is invisible, and it gives a page with no heading element a real accessible one. Look for an existing instance in the repo first — there usually is one, and matching it beats inventing a convention.

Two adjacent gotchas that cost real time

1. Check for layout resets before reasoning about inheritance. In SvelteKit, +layout@(app).svelte re-parents that branch to the (app) layout, so the intermediate layout's component does not render there. This is why one subtree route had exactly 1 h1 while its siblings had 2, and it is invisible from the route paths alone. Confirm empirically: curl the page and count <h1[\s>] occurrences per route before and after. (Related: the set of data-sveltekit-fetched blocks in the SSR HTML is an exact census of which loaders ran, so a missing block also proves a layout was reset out.)

2. You cannot put the <h1> inside the visible heading if that heading is wrapped in a <button>. A pattern where a card header is <svelte:element this={is_link ? 'a' : 'button'}> with the headline in a <span> blocks the obvious move of retagging the span. <button>'s content model is phrasing content; <h1> is flow content and is invalid there. The sr-only h1 in the page body sidesteps it without a design change.

Counting h1s: don't use a bounded quantifier

<h1[^>]*>([\s\S]{0,160}?)</h1> silently undercounts — any h1 whose inner content exceeds the bound simply fails to match, so a page with an h1 reports zero and you "discover" a defect that isn't there. Count with <h1[\s>] and extract separately, or the audit invents work.

No signals yet