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.