Cleaning Up My Blog's SEO and AEO with Claude in GitHub Copilot
Page content
I’ve been running this blog on Hugo for a long time, and like most blogs with real history behind them, it’s accumulated some debt. Some of it is my own fault. This blog started on WordPress, where it ran for seven years, and I migrated it to Hugo back in 2021. That migration exported those seven years of posts straight out of WordPress’s database and into raw markdown, HTML tags, inline styles, and all. I am not a good HTML programmer, and it showed. Broken tags, stray entities, image markup that only sort of worked, all of it scattered across hundreds of posts.
The first two times I sent Claude after this blog were cleanup, not SEO. The first round, earlier this year, fixed the worst of that broken HTML left over from the migration so pages would actually render correctly. The second round was a pass on spelling and grammar across the archive. Since then, the actual SEO and AEO work has happened across three more sessions, spread out over a few months, and I want to pull the whole story together here instead of leaving it scattered across git log messages only I’ll ever read.
Round One: Getting the Basics In Place
My first SEO session was small and mostly foundational. I added real meta tag and robots.txt scaffolding to baseof.html, wired up a custom link render hook, and added an author box partial so posts actually show who wrote them. I also found and deleted a stray draft post that had no business being in the content directory. Nothing glamorous, just the plumbing every site needs and mine didn’t have yet.
Round Two: Descriptions, Structured Data, and Performance
A little later I came back for a bigger pass, and this is where the site started actually improving instead of just getting cleaner:
- Added real meta descriptions to 30 evergreen technical posts
- Noindexed tag taxonomy pages while keeping categories canonical, which turned out to be the pattern the entire
noindexsystem in this post is built on - Built a custom
sitemap.xmlwith per-kind priority and age-based changefreq instead of Hugo’s flat default - Added
PersonJSON-LD to/about/withsameAslinks to LinkedIn, Bluesky, GitHub, and Pluralsight - Async-loaded Google Fonts and lazy-loaded images site-wide. Pure performance work, but page speed is a ranking factor too
- Built a proper custom 404 page with recent posts and category navigation instead of a dead end
- Consolidated inconsistent category names:
PowershelltoPowerShell(28 posts),SQLtoSQL Server(124 posts), with a 301 redirect so old links kept working - Added 10 new taxonomy landing pages with real descriptions instead of Hugo’s bare auto-generated list
- Cleaned up the header pages: a stale email address on
/contact/, broken hyphenation and tracking-junk Amazon links on/publications/, two dead links on/pure-storage-links/
That’s the foundation the most recent session built on top of.
Round Three: The Big One
This latest session, in August, was the deep pass: an honest audit first, then fixing everything the audit found, then adding the newer answer-engine-optimization layer on top of all of it.
The Starting Point
I asked for an honest audit first, no changes, just tell me what’s wrong. That’s the right way to start one of these sessions. The agent came back with a real list: broken internal links, meta descriptions falling back to a generic site description on most posts, images with alt text like NewImage or a raw macOS screenshot filename, and a chunk of posts that were dragging down my indexing without giving anything back.
Only after I saw the list did we start fixing things, one category at a time.
Broken Links and Redirects
Google Search Console had a coverage report showing pages that were 404ing. Some old aliases, some typos in internal links, some pagination artifacts nobody would ever click. We pulled the full list and fixed 14 of them with a mix of corrected links in the source posts and 301 redirects in staticwebapp.config.json.
Here’s the pattern for most of them:
{
"route": "/posts/2026-05-08-using-t-sql-snapshot-backup-hyperv-edition",
"redirect": "/posts/2026-05-08-using-t-sql-snapshot-backup-hyper-v-edition/",
"statusCode": 301
}
Important: don’t just redirect and forget. We verified every target actually resolves to real content before shipping, since a redirect to a page that itself 404s is worse than the original problem. That exact mistake showed up later in this post, more on that below.
Meta Descriptions on 109 More Posts
Meta descriptions matter more than people think. Without one, Hugo falls back to either the post’s summary or the site-wide description, which means a lot of pages end up with duplicate or generic descriptions in search results. On top of the 30 from round two, we found 109 more posts with no description field in the frontmatter, going all the way back to 2014. That’s 139 posts across both sessions.
The agent read the intro paragraph of each post and wrote a real, specific description for it, then added it to the frontmatter:
description: "Snapshot SQL Server databases spanning multiple Pure Storage FlashArrays using T-SQL Snapshot Backup with coordinated write I/O freeze across arrays."
109 of these, one at a time, each grounded in what the post actually says rather than a generic template.
Image Alt Text
This is the one that surprised me. Fifty images across the archive had alt text like NewImage (a leftover from an old screenshot tool), a raw macOS screenshot filename like Screen Shot 2018-10-13 at 8.24.02 AM, or nothing at all. None of that helps a screen reader, and none of it helps an image show up in search.
For each one, the agent looked at the actual image and wrote a real description:
<img src="/images/NTFSMFT.png" alt="Diagram of an NTFS Master File Table record showing standard info, file name, and data run headers mapping to disk block numbers" />
Fifty images fixed. Zero remaining generic or missing alt text anywhere on the site, confirmed with a full-site grep before we called it done.
Analytics and a Typo Sweep
While we were in there, I also added Ahrefs Web Analytics alongside the Google Analytics tag I already had, so I can cross-check traffic and keyword data between the two instead of relying on a single source. And since the agent was already reading every post to write descriptions and alt text, I had it grep the whole archive for common typos while it was at it. It found exactly one: “Enviroment” instead of “Environment,” in both a title and a heading on an old s5cmd post. Small, but free to fix once you’re already looking.
The AEO Layer: Structured Data for AI Answer Engines
This is the part I hadn’t touched before round three. AEO is the same idea as SEO, structure your content so a machine can extract and cite it, except the machine is Google’s AI Overviews, ChatGPT, or Perplexity instead of a classic search crawler.
Fixing Broken JSON-LD
First we found that Hugo’s built-in schema template was emitting broken structured data, itemprop attributes scattered around with no itemscope or itemtype to tie them together. Not valid, not useful. The Person JSON-LD I’d added to /about/ back in round two was fine, but everything else was broken.
The fix was real BlogPosting and WebSite JSON-LD, built with Hugo’s dict and jsonify functions. Here’s the gotcha that bit us on the first attempt:
{{- $ld := dict
"@context" "https://schema.org"
"@type" "BlogPosting"
"headline" .Title
"datePublished" (.Date.Format "2006-01-02T15:04:05Z07:00")
}}
<script type="application/ld+json">{{ $ld | jsonify | safeJS }}</script>
Yes, a dict and one jsonify call, but here’s the thing: our first version mixed literal JSON braces with per-field jsonify calls instead of building the whole object at once. Go’s HTML template engine treats <script> blocks as JavaScript context, and it double-encoded every string, "headline" came out as "\"My Title\"" with escaped quotes baked into the value. Building the entire object as one dict and calling jsonify | safeJS exactly once fixed it. We verified by round-tripping the output through Python’s json.loads on the built HTML before shipping.
That brought the site up to five valid schema types: WebSite, BlogPosting, Person, and the two new opt-in ones below.
HowTo and FAQ Schema, Opt-In Per Post
For posts that walk through an actual procedure, HowTo and FAQPage schema let an answer engine extract the steps or the Q&A pairs directly. I didn’t want this forced onto every post, so it’s opt-in through frontmatter:
howto_steps:
- name: "Connect to Both Arrays"
text: "Connect to one ActiveCluster member array and to the third array using Connect-Pfa2Array."
faq:
- question: "Can an ActiveCluster volume be an async replication target?"
answer: "No. An ActiveCluster volume can be an async replication source, but not a target."
We started with one pilot post, my ActiveCluster async replication walkthrough, to prove it worked with zero visible rendering change, then scaled it out to 26 posts across my T-SQL Snapshot Backup series, the Pure Storage PowerShellSDK2 series, and the Kubernetes failover walkthroughs. Every one of them validated as real, parseable JSON before it shipped.
llms.txt
There’s an emerging convention, an llms.txt file at the site root that gives AI crawlers a curated map of your best content instead of making them guess. I added one pointing at my post archive, categories, and a few posts I consider the strongest examples of what this blog is about.
Pruning the Thin Content
Ten years of blogging means a lot of “Speaking at SQLSaturday X” and “New Pluralsight Course” announcement posts, most under 300 words, most with zero lasting reference value. Search Console was quietly flagging a big chunk of my archive as crawled but not indexed, and this thin content is almost certainly why.
Rather than delete a decade of history, we added a noindex flag that a post can opt into, the same pattern from round two’s tag taxonomy work, just extended to individual posts:
noindex: true
38 posts got this treatment: event announcements, course launches, award renewals. We also excluded every noindex post from sitemap.xml, since there’s no point telling Google to crawl a page you just told it not to index. Everything stays on the site, still reachable, just not competing for search ranking against posts that actually answer a question.
The One Time It Broke Something
Not everything went smoothly, and I want to be honest about that part too.
Earlier in the session, to make the sitemap’s lastmod dates reflect real edit history instead of always showing the original publish date, we set enableGitInfo = true in config.toml. It worked perfectly on my machine. It broke the deploy pipeline.
Turns out Azure Static Web Apps builds the site inside a Docker container via Oryx, and that container’s user doesn’t own the checked-out repo. Git’s “dubious ownership” safety check, introduced a few years back after a real CVE, refused to let Hugo read the commit log, and the whole build failed silently until someone actually looked at the GitHub Actions run.
ERROR Failed to read Git log: fatal: detected dubious ownership in repository at '/github/workspace'
Error: error building site: logged 1 error(s)
We caught it by checking the actual Actions run, reverted enableGitInfo back to false, and the pipeline went green again. I lost the accurate lastmod dates, but a working deploy pipeline matters more than a nice-to-have freshness signal. Noted it in my repo memory so we don’t repeat the mistake.
The Numbers
Here’s the before and after, across all three sessions:
| Category | Before | After |
|---|---|---|
| Broken internal links | 14 | 0 |
| Posts missing meta descriptions | 139 | 0 |
| Images with missing/generic alt text | 50 | 0 |
| Inconsistent category names | 152 posts | 0 |
| Valid schema types site-wide | 0 (broken microdata) | 5 (WebSite, BlogPosting, Person, HowTo, FAQPage) |
| Posts with HowTo/FAQPage schema | 0 | 26 |
| Thin posts diluting search index | 38 | 0 (noindexed, not deleted) |
Why This Matters
Bringing all three sessions together, here’s what changed:
- Zero broken internal links — every redirect target verified to resolve to real content, not just redirect-and-hope
- 139 posts with real, specific meta descriptions instead of falling back to a generic site-wide summary
- Zero images with missing or generic alt text across the entire archive, up from 50 broken ones
- Five valid, real structured data types site-wide (
WebSite,BlogPosting,Person,HowTo,FAQPage), replacing structured data that was silently broken - 26 posts with HowTo/FAQPage schema, giving AI answer engines something concrete to extract and cite
- 38 thin posts noindexed, without deleting a decade of history, on top of tag taxonomy pages already noindexed and excluded from a custom
sitemap.xml - A working
llms.txtpointing crawlers at my best content - 152 posts recategorized into consistent, correctly-cased categories, backed by 10 new taxonomy landing pages instead of Hugo’s bare auto-generated lists
- A real 404 page with recent posts and category navigation instead of a dead end
- A faster, more consistent site, from lazy-loaded images and async fonts to header pages that no longer link to dead addresses or broken URLs
Wrapping Up
The technical SEO work was mechanical once we had the list: broken links, missing descriptions, bad alt text, inconsistent categories. The AEO side is newer territory for me, and it’s clear this is where the next few years of “how do people find my content” is heading. Structured data that a language model can actually parse and cite matters just as much as a page that ranks on page one.
This turned into three sessions instead of one, spread across a few months, and that’s fine. If you’re running a blog with any real history behind it, I’d bet you have some version of this same debt sitting in there. Get an honest audit first, then fix it one category at a time. And check your CI logs after any config change that touches how the site builds, not just your local machine.
Let me know how it works in your environment.