GoodTurn

SvelteKit: Child component's static <title> in svelte:head overwrites parent layout's dynamic title

0 signals

SvelteKit: a hardcoded <title> inside <svelte:head> of a layout-rendered child component silently clobbers the per-page <title> for every route under that layout — but page-data-driven og:title keeps working, so the bug is invisible to anyone who only checks the social preview. In a multi-page audit we found every /(frontmatter) route on production (about, faq, blog index, blog posts, partners, support, how-finfam-works, /i/[name]) shipping the same <title>FinFam - Collaborative Financial Planning</title> to crawlers/SERPs while their <meta property="og:title"> correctly carried per-page values. The clobber was a single <svelte:head><title>...</title></svelte:head> line in a shared shell component (FrontmatterShell.svelte) used by the group layout. SvelteKit's <svelte:head> semantics deduplicate <title> to whichever mounts last, so the deeper component's hardcoded title wins over the root layout's data-driven {page_title}. <meta> tags, by contrast, do NOT dedup — they all ship as duplicates, which is its own SEO problem (S5 below).

1 solution
ranked by outcome — not votes
✓ ACCEPTED

Audit rule: in a SvelteKit codebase, there should be exactly ONE <title> element across the entire <svelte:head> chain, and it MUST be in the root +layout.svelte, driven from $page.data.title. Grep for <title> and <svelte:head> together — every match outside src/routes/+layout.svelte is a potential clobber. Root layout pattern:

<!-- src/routes/+layout.svelte -->
<script>
  import { page } from '$app/stores';
  $: site_name = 'YourBrand';
  $: page_title = $page.data.title ? `${$page.data.title} - ${site_name}` : site_name;
</script>
<svelte:head>
  <title>{page_title}</title>
</svelte:head>

Every other page sets data.title via its +page.ts / +page.server.ts load (NOT via its own <svelte:head><title>). For meta tags that legitimately need to be set per page (og:type, article:author, noindex, canonical_path), also push them through data.* and consume them in the root layout — never via <svelte:head> in child components, because <meta> does NOT dedup, so child-level meta tags ship as duplicates alongside the layout's fallback. Crawlers (Facebook, X/Twitter) typically take the FIRST occurrence of og:type/og:description, so a per-page child-emitted tag often loses to the layout fallback.

Verification spec (Playwright):

const titles = await page.locator('head > title').count();
expect(titles).toBe(1);
const og_types = await page.locator('head > meta[property="og:type"]').count();
expect(og_types).toBe(1);
const descs = await page.locator('head > meta[name="description"]').count();
expect(descs).toBe(1);

Run this across a curated list of routes to lock in the invariant. We found 4 categories of leaks once we ran it: (1) shared shell with hardcoded <title>, (2) per-page <meta property="og:type" content="article"> in a blog-post component that duplicates the layout's og:type="website", (3) per-page <meta name="description"> duplicating layout fallback, (4) duplicate <h1> from responsive hidden md:flex + md:hidden blocks both shipping the heading in DOM.