seo/single-h1 · Heading hierarchy
Each page should have exactly one <h1>.
Severity: warning (no <h1>) / info (multiple <h1>s)
What it checks
Flags a page with zero <h1> (no primary heading, warning) or two or more <h1> (info). Exactly one <h1> passes. Headings from the page’s layout chain count toward the route, so an <h1> in +layout.svelte is credited.
A global severity override (rules: { 'seo/single-h1': <severity> }) applies to both arms. It flattens the split to a single severity for every finding this rule produces, since config keys on rule id, not on which arm fired.
Why it matters
The <h1> names a page’s main topic. Zero <h1> leaves the page without a primary heading, a page genuinely missing this signal, hence warning. A single, clear <h1> is the conventional signal for a page’s topic, but multiple <h1>s are tolerated by modern heading algorithms; no official source documents a ranking penalty for having several, so that arm is flagged as a style nit (info), not a defect.
How to fix
<h1>The page's single, descriptive main heading</h1>
<h2>A subsection</h2>
<h2>Another subsection</h2>
Mode differences
Headings are collected in both modes, but from different sources, so results can differ:
- Source analysis (the CLI, the dashboard’s static baseline) walks the route’s
.sveltetemplates, including headings rendered by imported local components (followed transitively, depth-limited, the same traversal used for head resolution), so extracting a page’s<h1>into a$libcomponent is credited. Components re-exported through a barrelindex.ts, namespace members (<Ui.Title>) and aconst/$derived/{@const}holding an imported component, or aleta function fills withimport(), are followed, and so is a markdown module rendered as a component (an mdsvex.mdor.svximport), read for its ATX headings; when it holds one of several, an<h1>in any of them keeps the route from being reported as missing one. A component loaded withimport()renders only in the browser, so its<h1>does this only on a route withssr = false. The arms of one{#if}/{:else if}/{:else}or{#await}block never render together, and neither do a<svelte:boundary>’s content and itsfailed/pendingsnippets, so only the arm with the most<h1>s counts. An arm includes the components placed in it, and the page renders inside the arm of its layout’s<slot />/{@render children()}. A layout that renders them in several arms of one{#if}places the page in each of those arms; places in separate blocks leave it where they all agree. An arm whose test readspage.url.pathname(or$page.url.pathname, directly or through a$derived) withstartsWith,endsWith,includesor===against a string literal is left out on a route whose path cannot satisfy it. The same reading decides any{#if}in a layout or page by the route, and its test may also bepage.route.id(or$page.route.id) against a string literal or in an array literal or aconstlist of strings the file never changes (['/a', '/b'].includes(page.route.id)), a regex literal’s.test()of the path, a.someover such a list testing the path’sstartsWith,endsWithorincludesof each entry (PUBLIC.some((r) => page.url.pathname.startsWith(r))), or the path’s===against a template literal (`/c/${slug}`, each${…}taken as the route parameter in its place). An imported binding that one of the app’s own modules exports by name as aconstset to the literalfalse(export const FLAG = false) decides an{#if}whose result it fixes on its own, and the arm it rules out is left out too: the first arm of{#if FLAG}and{#if FLAG && …}, the{:else}of{#if !FLAG}and{#if !FLAG || …};{#if !FLAG && …}and{#if FLAG || …}stay undecided; when that leaves no place the layout renders its children, the page and the layouts below it are not counted on that route. Content passed to a component (its children, or a{#snippet}in its tag) sits in the arm where that component renders it, so a wrapper that puts{@render children()}and an error screen in one<svelte:boundary>or{#if}does not add its error<h1>to the page it wraps; content passed to a component that cannot be followed stays where it is written. A snippet the file renders counts at each{@render}of it, in that{@render}’s arm. Separate blocks still add up, because both conditions may hold, except{#if}blocks without an{:else}in one arm (no other block,{#each}or component between them; elements may be) whose conditions contradict each other on one value ({#if pitch}and{#if !pitch},{#if step === 0}and{#if step === 1}, or==to two different numbers, also as one condition of an&&), which count as arms of one block. So do any two places in one file below{#if}blocks without an{:else}whose conditions, read together from the outermost block in, cannot all hold ({#if a || b}against{#if !a}and, inside it,{#if !b}), and a place below{#if}blocks without an{:else}whose conditions leave one arm of a block with an{:else}counts as part of that arm ({#if signedIn}<h1>…{/if}against an<h1>in the{:else}of{#if signedIn}); conditions are read as&&,||and!of references and of their===/!==to literals, and one inside an{#each}, a snippet with parameters or an{:then}is left out, since it is read against several values. An{#if}in a component that one prop decides ({#if isHome},{#if !title},{#if variant === 'detail'},{#if typeof title === 'string'},{#if title.length > 0}) counts only the arm that prop selects, when the use passes it literally or not at all (its default, orundefined) — also through a$derivedthat only trims or defaults it ($derived(title?.trim() ?? '')), and with a call given a non-empty string literal (a translation call such ast('Title')) counting as non-empty text for{#if title}—, and when a component in between passes on a prop it was given literally without changing it (<Media {view} />); a prop passed as an expression or spread, bound, or written by the component leaves both arms counted. A test joining such tests with&&or||({#if title || header}) is decided when the use decides each of them. The prop may also be read as a member of an undestructured$props()binding (let props = $props(),{#if props.title}). A$derivedholding such a test decides an{#if}on it the same way (const embedded = $derived(variant === 'embedded'),{#if embedded}). It still counts headings in branches that may not render (e.g. inside{#if false}, or behind any other runtime condition), and it cannot see components from npm packages the checkout has not installed (a workspace package the app declares is followed, and so is an installed Svelte package, see Configuration) or a component picked from data or a lookup table at runtime. A heading inside an unresolvable component can still produce a false “Missing<h1>”, and<h1>s in separate conditional blocks can produce aninfo-level over-count. An element withrole="heading"is not an<h1>, whatever itsaria-level. A<svelte:element this={…}>counts as the element it resolves to when the tag expression is a string literal, or a conditional whose branches are both literals and name one heading level (this={featured ? 'h1' : 'span'}). When the tag is not determinable that way —this={`h${level}`}, a variable, or a conditional between two different heading levels — the element may be the page’s<h1>, so the route is left unreported rather than flagged as missing one. Anything inside an<svg>(without a<foreignObject>) is never a heading, so an icon component that draws its shapes with<svelte:element this={tag}>does not do this. The same applies to a component it cannot follow (from a package that is not installed, for instance) that is giventag,as,elementoriswith the literal valueh1(<Heading tag="h1">): the component may render the page’s<h1>, so the route is left unreported. Other props (value="h1") and other levels (tag="h3") say nothing about the<h1>and do not. - Rendered analysis (the Vite plugin’s build pass, a route you visit in the dashboard) reads the rendered HTML, so it sees every component-rendered heading and only the branches that actually rendered.
When the two disagree, trust the rendered result (@svelte-vitals/vite). It reflects what ships to the browser.
Disabling
Record existing findings in the suppressions file (npx svelte-vitals --update-suppressions), scope the rule per route or path with overrides, or turn it off:
export default {
rules: {
'seo/single-h1': 'off'
}
};