Back to Blog

Your SEO fix passed.
That does not mean it worked.

I found the worst bug on my site because I forgot to substitute a placeholder in a command. Six ways my verification was wrong, and what I check now instead.

Your SEO fix passed. | That does not mean it worked.

A few weeks ago I migrated my own site from a React single page application to Astro, because crawlers were receiving an empty div instead of any content. The migration itself took four hours. Fixing everything it exposed took considerably longer: missing metadata, invisible headings, a render blocking font chain, soft 404s.

Each of those had a fix, and each fix needed confirming. That is where the real time went.

I found the worst problem on the site by making a mistake.

I was checking cache headers on a font file, and I had written the command with a placeholder:

bash
curl -sI https://mikehiggins.me.uk/_astro/FILENAME.woff2 | grep -i 'HTTP'

I forgot to substitute the filename. FILENAME.woff2 does not exist and never has. It returned:

HTTP/2 200

A file that definitely was not there came back as found. A leftover catch all rule was serving the application for every unmatched path, which meant every mistyped URL, every dead link and every stale path on a 66 page site was returning 200 with content. Google treats that as a soft 404.

I had not been looking for it. I would not have found it that day. It surfaced because a command failed in a way I could not explain.

That turned out to be the pattern for the entire project. Roughly half the time I spent fixing technical SEO problems was actually spent discovering that the check I had used to confirm a fix was wrong. Here are the ways that happened, because they are all reusable mistakes.

1. The check that cannot fail

I needed to confirm no page in the build contained a particular pattern. So I wrote:

bash
grep -rl 'opacity:0' dist/*.html dist/**/*.html

It returned nothing. Clean sweep, job done.

Except bash does not expand ** recursively unless globstar is enabled, and it is off by default. That second pattern matched almost nothing. The check passed because it barely looked.

The correct version:

bash
grep -rl 'opacity:0' dist --include='*.html'

Which immediately found the files the first command had sailed past.

The lesson is not about globstar. It is that a check returning the result you hoped for is the least informative outcome there is. Before trusting a passing check, make it fail on purpose. Point it at something you know is broken. If it still passes, it was never testing anything.

2. The check that counts the wrong thing

I wanted to count URLs in a sitemap:

bash
curl -s https://mikehiggins.me.uk/sitemap-0.xml | grep -c '<loc>'

It returned 1. I briefly concluded the sitemap was broken.

It contains 66 URLs. The file is generated as a single line with no newlines, and grep -c counts matching lines, not matching occurrences. One line, one match.

I made the same mistake again later, counting links on a page:

bash
curl -s https://mikehiggins.me.uk/ | grep -c 'href="/about"'

Also returned 1. There are two such links. Same cause: minified HTML is one long line.

The fix is to count occurrences rather than lines:

bash
curl -s https://mikehiggins.me.uk/sitemap-0.xml | grep -o '<loc>' | wc -l

Minified output breaks a surprising number of line based assumptions. If your build pipeline strips newlines, and most do, then grep -c, head -n, wc -l and anything else that thinks in lines is measuring something other than what you meant.

3. The check that tests the wrong thing entirely

After adding per page metadata, I verified it:

bash
curl -s https://mikehiggins.me.uk/ | grep -o 'rel="canonical" href="[^"]*"'

Correct canonical. Correct title. Correct description. Fix confirmed.

Except the homepage is the page that is almost never wrong. The two failure modes that actually matter with per page metadata are reusing the homepage description across every page, and hardcoding the canonical so all pages point at the root. A homepage check detects neither, by construction.

The check that would have found them:

bash
for u in / /about /projects /blog /scrapbook; do echo "=== $u" curl -s "https://mikehiggins.me.uk$u" | grep -o 'rel="canonical" href="[^"]*"' curl -s "https://mikehiggins.me.uk$u" | grep -o '<title>[^<]*</title>' done

Ask what a broken version would look like, then write the check that distinguishes it from a working one. If the same output appears either way, the check is decorative.

A related version of this: I tested a subset of five pages for weeks before thinking to test all 66. The sitemap is right there and makes a full sweep trivial:

bash
curl -s https://example.com/sitemap-0.xml \ | grep -o '<loc>[^<]*' | sed 's/<loc>//' \ | while read u; do # your check here done

That sweep found three pages with a problem I had already declared fixed.

4. The check reading stale data

Two separate instances of this.

I added a descriptive label to a link, deployed, and re-read the PageSpeed Insights report. Still flagged. I assumed the deploy had failed.

PageSpeed Insights caches results. I was reading a report generated before the deploy. Reloading the page does not regenerate it; you have to trigger a fresh analysis.

Worse, for a long stretch I was testing my homepage through a tool that had cached it on first fetch and kept serving me that copy. Every subsequent "check" returned the pre migration page. I only noticed because the response contained a meta description I had replaced days earlier.

Verify against something that cannot be cached, or prove freshness before trusting the result. A quick test is to check for a string you only just added. If it is absent, you are reading history.

5. The check that is too broad

I swept the site for inline opacity:0, which had been hiding text until JavaScript loaded. Three pages still matched after the fix.

Both were false positives.

One was a Radix UI hidden input, the standard pattern for keeping a native form control accessible beneath a custom one:

html
style="position:absolute;pointer-events:none;opacity:0;margin:0;transform:translateX(-100%)"

That element is meant to be invisible permanently. The other was a CSS keyframe inside a blog post:

css
@keyframes pulse-out { 0%{opacity:.7} 100%{opacity:0} }

An animation ending in a fade out. Nothing hidden, nothing waiting on hydration.

The real bug was narrower than the string I searched for: an inline style attribute carrying opacity:0 on an element containing text. So:

bash
grep -o '<[^>]*style="[^"]*opacity:0[^"]*"[^>]*>[^< ]\{3,\}'

Requiring text immediately after the tag excludes both the self closing input and anything inside a style block.

This one nearly caused real damage. I had drafted an instruction demanding zero matches for the substring across all built HTML. That target is unachievable on any site using Radix, and the only way to hit it would have been to strip the hidden input styling and break form accessibility to satisfy a grep.

A check that cannot be satisfied without breaking something is worse than no check at all, particularly if you are handing it to an automated agent that will try very hard to satisfy it.

6. The fix that did not do what I thought

Lighthouse flagged a link as having non descriptive text. I added an aria-label, on the reasoning that the audit evaluates the accessible name.

Deployed. Confirmed the attribute was present on the correct element. Re-ran the report. Still flagged.

My reasoning was simply wrong. For that audit, the label did not override the text content.

The working fix appends visually hidden text instead, so the text content itself becomes descriptive:

html
Learn More<span class="sr-only"> about Mike Higgins</span>

Nothing changes visually. The text content now reads "Learn More about Mike Higgins".

It is also the better fix for a reason unrelated to the score. aria-label replaces the visible text for assistive technology, so a voice control user saying "click Learn More" may no longer match the element. The hidden span appends rather than replaces.

Being able to explain why a fix should work is not evidence that it does. The explanation and the outcome are separate claims and need separate verification.

What I would take from this

A passing check is a claim about reality, and claims need to be falsifiable.

Most verification I see, including most of my own in this project, tests whether the string you expected to add is present. That is a much weaker statement than the problem being gone, and the gap between the two is where this all lives.

Four habits that would have saved me most of the time I lost:

Make the check fail first. Run it against known broken input. If it passes, it is not a check.

Write the check before the fix. Then it genuinely cannot be shaped around whatever you happened to produce.

Check the thing, not a proxy for it. HTTP status rather than the presence of a file. Rendered output rather than source. All pages rather than the homepage.

Prefer checks that can surprise you. The best one I ran all week was a mistake, and it found a problem across every URL on the site.

The uncomfortable implication is that most of the technical SEO work I see confirmed as done has been confirmed by exactly the kind of check described above. Present, correct, and testing the wrong thing.