All posts

The head tag that told Google my site was one page

, 4 min read

For a while, every page on this site told Google the same thing: this page is a copy of the home page, don’t index it.

Not by accident of wording. Literally, in a tag, on every URL:

<link rel="canonical" href="https://nabenshrestha.com.np" />

The blog index said it. Every post said it. The case study said it. The arcade said it. Five distinct pages, each one formally nominating a different page as the one worth indexing.

Nothing looked wrong. The pages rendered correctly, the content was there, the links worked, Lighthouse was happy. I found it by reading the HTML of a page I had no reason to suspect.

What a canonical actually promises

rel="canonical" is not a hint about your preferences. It is a claim about identity: this URL and that URL are the same document, and that one is the original. Search engines use it to collapse duplicates — the printer- friendly version, the one with tracking parameters, the same product under three category paths. Exactly one URL survives into the index and inherits whatever authority the duplicates had.

So a site where every page names the home page is a site making a coherent, emphatic, entirely false statement: there is one document here. Everything else is a copy.

That is a complete instruction to index nothing but the front page. It is also, from a crawler’s perspective, an unambiguous one. There is no contradiction for it to resolve, no signal fighting another signal. The site was very clear. It was clear about the wrong thing.

Why it was invisible

The head was written once, in app.html, the way you would write it in a plain HTML file:

<head>
  <link rel="canonical" href="https://nabenshrestha.com.np" />
  <title>Nabin Shrestha — Frontend Engineer in Kathmandu, open to remote roles</title>
  <meta name="description" content="..." />
  <!-- og:, twitter:, JSON-LD, all of it -->

  %sveltekit.head%
</head>

That last line is the whole story. %sveltekit.head% is where SvelteKit injects whatever the current route declared in <svelte:head>. It is an insertion point, not a replacement. Nothing in the templating system knows that the <title> twelve lines above is supposed to be superseded by the one the route provides. Both are emitted. Both ship.

So a blog post shipped two <title> elements and one canonical, and the canonical was the hardcoded one, because the routes never declared a canonical of their own — only titles. The hardcoded tag had no competition.

Here is the part I find genuinely unfair. In the file, the hardcoded canonical was on line 8, the hardcoded title on line 11, and the placeholder on line 78. First tag wins in browsers, so the tab on every single page read Nabin Shrestha — Frontend Engineer in Kathmandu, open to remote roles, even while the page below it was a blog post with its own title rendered perfectly in the DOM.

The symptom was on screen the entire time. It was in the one part of the window nobody looks at while building the part they are building.

The fix, and why it is a component

The instinct is to delete the canonical from app.html and move on. That fixes this bug and leaves the shape that produced it.

The real problem is that app.html has no notion of “per route”. Anything written there is unconditional and site-wide, so it is the correct home for exactly one category of tag: things genuinely identical on every URL — charset, viewport, the favicon. Titles, descriptions, canonicals, Open Graph and structured data are all route-owned by definition. They belong wherever route data lives.

So they moved into a component the route renders:

<script lang="ts">
  import { page } from '$app/state';
  import { canonicalFor, titleFor } from './seo';

  let { title, description }: Props = $props();

  const canonical = $derived(canonicalFor(page.url.pathname));
</script>

<svelte:head>
  <title>{titleFor(title)}</title>
  <link rel="canonical" href={canonical} />
  <meta name="description" content={description} />
</svelte:head>

One subtlety worth stealing. canonicalFor builds the URL from page.url.pathname and a hardcoded origin, never from page.url.href. During prerendering the origin is SvelteKit’s internal placeholder, not your real hostname — so a canonical derived from the full URL ships pointing at a host that does not exist. The path is the only part of page.url worth trusting at build time.

app.html now carries a comment explaining what it must never contain again. Which is how I found the second bug.

The comment that ate the head

The obvious way to warn the next person is to name the thing:

<!--
  Don't write tags here. The %sveltekit.head% placeholder below
  appends rather than replaces...
-->

%sveltekit.head% is a plain string replacement. It has no idea it is inside a comment. SvelteKit found the first occurrence — the one in the warning — and injected the entire head there.

Two things happen at once. Every tag in the injected head is now inside an HTML comment, so the page ships no title, no canonical, no metadata at all. And because the injected markup contains a comment of its own, its --> closes the outer comment early, spilling the rest of the warning text onto the rendered page as visible content.

A comment written to prevent an SEO bug caused a worse one. The warning now describes the placeholder without naming it.

How to check yours

One line, and it works on any site, not just SvelteKit ones:

curl -s https://your-site.com/some-inner-page | grep -c 'rel="canonical"'

You want exactly 1. Zero means nothing is declared. Two or more means something is emitting a second one, and you now get to find out which wins.

Then look at what it says:

curl -s https://your-site.com/some-inner-page | grep -o 'rel="canonical" href="[^"]*"'

If an inner page names your home page, you have this bug.

Across the four inner pages of this site, that now returns one canonical each, each naming itself. What it cost to get there is not a redeploy — Google had already learned the site’s shape from months of consistent, wrong information, and unlearning it takes weeks of recrawling.

The lesson I actually took: a rendering bug shows up in the render. A metadata bug shows up nowhere a human is looking. The only defence is checking the bytes you serve rather than the page you see, and the check above is short enough that there is no excuse not to.