Skip to main content

Astro slot=head drops silently from nested components

Illustrated receipt card summarizing: Astro slot=head drops silently from nested components

A competitor’s public page was shipping BreadcrumbList JSON-LD twice — once in <head>, once mid-body — the kind of greppable mistake worth borrowing from. Checked the local repo: same risk. The fix attempt — moving the schema out of a nested component’s body and into a <script slot="head"> block — caused the entire BreadcrumbList to vanish from the rendered page. No build error, no DevTools warning, no console message. Search Console rich-result validation: BreadcrumbList: 0 items detected. The rule that explains it is not in Astro’s slot docs.

What I ran

Not a skill activation. Competitor-debugging on a public page surfaced the duplicate-BreadcrumbList pattern via DevTools — two separate <script type="application/ld+json"> blocks, both containing @type: BreadcrumbList, different IDs, neither deprecated. Grep on the local repo found src/components/Breadcrumb.astro generating its own schema inline mid-body, plus a dead buildBreadcrumbList() function in src/lib/schema.ts whose JSDoc falsely claimed “Breadcrumb.astro uses this directly.” Verified: it didn’t. The component generated its own; the library function was orphaned.

The companion post on this site, /seo-schema validates JSON-LD well — it won’t extend it, covers the inverse failure mode: schema that’s present and correct but won’t carry new types. This post is the silent-zero case.

What happened

The refactor attempt — keep the markup component, just move the schema element so it ends up in <head> instead of in body:

<!-- src/components/Breadcrumb.astro (failed refactor) -->
<nav class="breadcrumb">...</nav>
<script slot="head" type="application/ld+json" set:html={JSON.stringify(schema)} />

Result: BreadcrumbList disappeared from the rendered page entirely. Not in <head>, not in <body>. The Search Console Rich Results test returned BreadcrumbList: 0 items detected. No build error. No DevTools warning. Nothing in the browser console.

Root cause: Astro’s slot="head" attribute works only on a direct child of the layout component. Place <script slot="head"> inside a nested component, and Astro drops the element from the render. The slot mechanism does not forward through component boundaries. Visually in DevTools the script just vanishes — no error, no fallback, no build warning. This is not documented as a constraint in the Astro 6 slot docs.

The working refactor — three rules fall out of the slot mechanic, applied as a single change across the codebase:

---
// src/pages/[entity]/[slug].astro
const breadcrumbItems = [
  { name: 'Home', url: '/' },
  { name: entity.name, url: `/${entity.slug}` },
];
const breadcrumbSchema = buildBreadcrumbList(breadcrumbItems, siteUrl);
---
<BaseLayout ...>
  <script slot="head" type="application/ld+json" set:html={JSON.stringify(breadcrumbSchema)} />
  <Breadcrumb items={breadcrumbItems} />
  ...
</BaseLayout>
  1. Breadcrumb.astro becomes markup-only — <nav>, <ol>, <li>. No schema generation.
  2. lib/schema.ts is the only place JSON-LD gets built. The dead buildBreadcrumbList() orphan is now live code; the misleading comment gets deleted.
  3. Page templates inject the schema with <script slot="head"> as a direct child of the layout. Not from a component. Not from a partial. From the page.

The breadcrumbItems array is the single source of truth — passed to both the schema builder and the visible component, no risk of the two drifting apart on future edits.

Where it drifted

The dead-code comment was actively harmful. "Breadcrumb.astro uses this directly" claimed a connection that wasn’t true. A reader trusts the comment, doesn’t grep for the function’s call sites, and treats the orphan as live code. A misleading comment on dead code is worse than no comment at all — it short-circuits the verification step that would have caught the orphan.

The slot drop is not documented as a constraint. Astro’s docs describe slot="head" as a mechanism for injecting head content from page-level code. Nothing explicitly says “must be a direct child of the layout; will be silently dropped from nested components.” The constraint is discoverable only by hitting this exact bug — or by reading the slot-resolution source.

Time-to-detect without the competitor-debug hook: estimated 4–8 weeks. That’s Search Console latency on rich-result regression flagging. By the time the dashboard tells you BreadcrumbList: 0 items, the deploy that broke it is buried under a month of other deploys. Time-to-detect with the hook: ~2 hours from competitor-DevTools to fix-deployed. The competitor’s visible mistake acted as a free regression test on the local architecture.

What I’d change

Adopt the architectural rule that survives refactors: JSON-LD lives in lib/schema.ts only, injected via <script slot="head"> from page templates only. Components are markup-only. The rule is short enough to fit in CLAUDE.md and prevents the whole class of bug.

When a competitor ships a visible schema bug, grep your own codebase for the same pattern before assuming you’re clean. The cost is one DevTools session and one grep. The avoided cost is weeks of Search Console latency on a regression that’s already in production.

Delete dead code with misleading comments first. A truthful comment can stay; an orphan with a fabricated call-site claim should be removed in the same commit you’d otherwise trip over it. 10 V1 page-type templates were verified post-refactor — each emits exactly 1 BreadcrumbList block in <head>, 10/10 in DevTools.