Writing
Three correct fixes that all failed
The site was not ranking. Three engineers had each diagnosed it, each shipped the correct fix, and each verified their own change. Nothing improved for a year.
The symptom
A marketing site with around sixty pages was behaving, in search results, as though it were one page.
The homepage ranked. Nothing else did. Not the pricing page, not any of the module pages, not the location pages. Search Console showed impressions collapsing onto a single URL, and every attempt to get anything else indexed produced nothing.
Three separate commits over several months had attempted to fix this. All three were titled some variant of “fix canonical tags”. All three were correct changes. None of them worked.
What everybody diagnosed
The canonical tag tells a search engine which URL is the authoritative version of a page. If pages do not declare one, or declare the wrong one, a search engine may treat several URLs as the same document and pick one to show.
So the diagnosis was obvious: the pages are missing per page canonical tags. And the fix was equally obvious: add them.
Each engineer went to the route templates, added the tag with the correct URL for that page, verified it appeared in their component, tested locally, and shipped.
Each one of them was right about what they had done. I want to be clear about that, because the interesting part of this story is not that three people were careless. Three careful people were individually correct and the system was still wrong.
What I did differently, which was almost nothing
I did not read the code. I fetched the page and read the HTML the server actually sent.
curl -s https://example.com/pricing | grep -i canonical
Two lines came back.
<link rel="canonical" href="https://example.com/" />
<link rel="canonical" href="https://example.com/pricing" />
Every page on the site was serving two canonical tags. The first said “this is the homepage”. The second said the correct thing. And the first one won, because when a document declares conflicting canonicals, they are discounted, and in practice the first in document order dominates.
Every page on the site had been telling Google it was the homepage. For a year.
Where the second one came from
One line in the shared root layout.
// routes/__root.tsx
head: () => ({
links: [
canonicalLink('/'), // <- this
...
],
})
Somebody had added a canonical to the root layout early on, when the site was one page, and it was correct at the time. The framework merges the head output of the root and the matched child route, so once child routes started declaring their own, every page got both.
The three previous fixes had each added the child canonical. Not one of them removed the root one, because the root one was not visible from where any of them were working. They were in the route file. The bug was in a file that had not been touched in two years and was not part of the change.
Why nobody saw it
This is the part worth keeping.
Every engineer verified their own change, and their own change was correct in isolation. To see the bug you had to look at something none of them had reason to look at: the merged output of two templates neither of which was individually wrong.
There is no code review that catches this, because the diff is right. There is no unit test that catches it, because the component under test emits one tag. There is no local check that catches it, unless the local check reads the rendered page rather than the source.
The bug did not exist in any file. It existed in the combination.
The fix, and the real fix
The fix was deleting one line.
The real fix was making the class of bug impossible, because a fix that depends on nobody ever reintroducing it is not a fix, it is a note to the future.
Two changes. First, the canonical is now derived rather than declared. It is computed from the page’s own path in exactly one layout, and there is no parameter to pass a different value. You cannot supply a wrong canonical because there is nowhere to supply one.
Second, a script that runs after the build, walks every emitted HTML file, and fails if any page has more than one canonical tag or if the canonical does not match the file’s own location.
const canonicals = (html.match(/<link[^>]+rel=["']canonical["'][^>]*>/gi) ?? []).length;
if (canonicals !== 1) errors.push(`${page}: ${canonicals} canonicals, expected 1`);
Eleven lines. If anybody ever adds a second one from a shared layout again, the build fails with the page name.
That script is running on this site. On its very first run it caught something unrelated, a stray HTML file left in the public directory by the previous version, which would have shipped as an indexable page with no title and no canonical. I had not known it was there.
What I actually took from it
Three things, in order of how much they have changed how I work.
Read the output, not the source. The source is what somebody intended. The output is what is true. When those two disagree, every hour spent in the source is wasted. This has become the first thing I do on any unfamiliar bug, and it is startling how often the answer is immediately visible.
Be suspicious of confidence, especially collective confidence. Three people had verified this. The verification was the problem, because everybody verified the same wrong layer. When several competent people have all failed at something, the shared assumption is the suspect, not their competence.
A fix that relies on memory is not a fix. The line was deleted three times, in a sense, and it survived because nothing prevented it. Every time I find a bug now, the second question after “how do I fix this” is “how does this become impossible”, and if the honest answer is “everybody has to remember”, then it is not finished.
The uncomfortable postscript
Recovery was not instant. Google had spent a year learning that this site was one page, and correcting the tags did not undo that on the day. It took a corrected sitemap, indexing requests on the pages that mattered, and weeks of waiting.
That is worth saying because it is the part that gets left out. The bug cost a year of ranking, and the fix cost one line and eleven lines of test. The gap between those two numbers is the entire argument for reading the output.