On a static Astro site, publishing is one git merge away from live. No staging soak, no gradual rollout — the build runs, the CDN swaps, and sixty seconds later your post is in front of everyone. That speed is the whole appeal. It’s also the trap: there’s no window in which a human notices something looks off before the world does.
And the bugs that hurt an Astro blog most are precisely the ones you can’t see while writing. A post that renders <html lang="es"> when it’s written in English. A canonical tag pointing at a URL that 404s. An hreflang alternate to a page that doesn’t exist. A “Related posts” widget quietly surfacing the wrong language. None of these show up in your Markdown, your editor, or even your dev server the way you’d catch a typo — they live in the built HTML, and they fail silently. So before any post goes live, I run a short checklist against the dist/ output. Here’s the whole thing, with the actual scripts.
Why you check the build output, not the source
Your .mdx file is not what ships. Astro runs it through MDX, your theme’s components, remark/rehype plugins, and (in my case) an HTML compressor. What lands in dist/ is the product of all of that — and it’s where the meta tags, structured data, canonical, and hreflang are actually assembled, often from config files three directories away from the post you’re editing.
That’s the mental shift: you’re not reviewing your writing, you’re auditing a generated artifact. So the first step is always to produce it:
bun run build # emits dist/ exactly as it will deployEverything below runs against that folder.
Check 1: assert the rendered lang — don’t trust the frontmatter
My blog derives a page’s language from its URL, not its frontmatter. So a post with locale: en that ends up served from the wrong route renders <html lang="es"> — English content labeled Spanish to every search engine and screen reader. I’ve shipped exactly that bug. It is invisible unless you look at the built tag:
grep -oE '<html[^>]*lang="[^"]*"' dist/en/my-post/index.html
# → <html ... lang="en"> ✅ (anything else on an English post is the bug)The rule: verify the attribute in the output, not the intent in the source. Frontmatter is what you asked for; the rendered tag is what shipped.
Check 2: canonical and hreflang actually resolve
A canonical or hreflang pointing at a URL that returns 404 is worse than not having one — it can deindex the page or invalidate the whole language cluster. I pull the real tags out of the built file, attribute order be damned:
POST=dist/en/my-post/index.html
grep -oiE '<link [^>]*canonical[^>]*>' "$POST" # self-referencing, correct URL?
grep -oiE '<link [^>]*alternate[^>]*>' "$POST" # hreflang set — every href must 200For content that exists in only one language, the alternates should self-reference only. If an English-only post advertised a Spanish alternate, that alternate would 404, and a broken cluster gets ignored wholesale. (I go deep on why in the Astro SEO setup post — canonicals and hreflang are the two easiest things to get subtly, silently wrong.)
Check 3: a dead-link + dead-hreflang crawler over the whole build
Spot-checking one file doesn’t scale, and the nastiest dead links are the ones you introduce by linking between posts — including a link to a post that isn’t merged yet. So I keep a ~40-line script that walks every HTML file in dist/, resolves every internal <a href> and every hreflang against the actual files on disk, and reports anything that wouldn’t resolve:
// check-links.mjs — walk dist/, flag internal links + hreflang that don't resolve to a file
import { readdirSync, readFileSync, existsSync, statSync } from 'fs';
import { join } from 'path';
const dist = process.argv[2] || 'dist';
const origin = 'https://heroweb.dev';
function walk(dir) {
let out = [];
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) out = out.concat(walk(p));
else if (e.name.endsWith('.html')) out.push(p);
}
return out;
}
// a URL "resolves" if it maps to a real file: /foo → dist/foo/index.html | dist/foo.html
function resolves(path) {
const rel = path.split(/[?#]/)[0].replace(/^\//, '').replace(/\/$/, '');
if (rel === '') return existsSync(join(dist, 'index.html'));
return [join(dist, rel, 'index.html'), join(dist, rel + '.html'), join(dist, rel)]
.some((c) => existsSync(c) && statSync(c).isFile());
}
const dead = new Set();
for (const f of walk(dist)) {
const html = readFileSync(f, 'utf8');
const page = f.replace(dist + '/', '');
for (const m of html.matchAll(/<a[^>]*href="([^"]+)"/g)) {
let h = m[1].startsWith(origin) ? m[1].slice(origin.length) || '/' : m[1];
if (!h.startsWith('/') || h.startsWith('//')) continue; // skip external
if (h.split(/[?#]/)[0] === '') continue; // skip pure #fragments
if (!resolves(h)) dead.add(`${h} (link on ${page})`);
}
}
console.log(dead.size ? [...dead].join('\n') : 'NONE');node check-links.mjs
# --- dead <a> links --- NONE ✅No dependencies, no crawler library, no running server — it reads files. It runs in a fraction of a second and it has caught real dead links for me, including cross-post links where the target post lived in a branch I hadn’t merged yet. (The full version I use also collects the hreflang hrefs and every /en/* target the nav emits, so I can eyeball the language routing at a glance.)
Check 4: no cross-language leaks
“Related posts” and “Latest posts” widgets pull from your whole collection unless you filter them. On a bilingual site that means an English post can surface as a “related” card on a Spanish page — a valid link, so the crawler won’t flag it, but wrong for the reader and for language signals. I check the widget output directly:
# on a Spanish page, there should be zero links into /en/<post> from the post cards
grep -oiE 'href="/en/[a-z0-9-]+"' dist/some-spanish-post/index.htmlThe only /en/ link that belongs on a Spanish page is the language switcher. Anything else is a leak.
Check 5: structured data that actually parses
I emit BlogPosting JSON-LD on every post — and a malformed JSON-LD block is worse than none, because it can suppress the rich result entirely. So I parse every ld+json block in the build and check the required fields, rather than trusting that it looked right:
// pull every <script type="application/ld+json"> and JSON.parse it; flag invalid or incomplete
const re = /<script type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/gi;
let m;
while ((m = re.exec(html))) {
try {
const o = JSON.parse(m[1]); // throws on malformed JSON
if (o['@type'] === 'BlogPosting') {
for (const req of ['headline', 'datePublished', 'author', 'image', 'mainEntityOfPage'])
if (!o[req]) console.log(`⚠ BlogPosting missing ${req}`);
}
} catch (e) {
console.log(`✗ invalid JSON-LD: ${e.message}`);
}
}Then, once, I paste a live URL into Google’s Rich Results Test for the visual confirmation. The local parse catches the “it doesn’t even parse” failures before they ever ship.
Check 6: accessibility basics — one h1, alt on every image, a lang attribute
The cheap a11y wins are also SEO signals, and they’re easy to regress from a theme change. One pass over dist/ covers the three that bite most:
for (const f of walk('dist')) {
const html = readFileSync(f, 'utf8');
const h1 = (html.match(/<h1[\s>]/gi) || []).length;
if (h1 !== 1) console.log(`h1=${h1} ${f}`); // want exactly one
for (const img of html.match(/<img\b[^>]*>/gi) || [])
if (!/\salt=/i.test(img)) console.log(`img without alt ${f}`);
if (!/<html[^>]*\slang=/i.test(html)) console.log(`no lang ${f}`);
}Running this is how I found a post with two <h1> tags (the Markdown opened with # Title, duplicating the title the template already renders) and another with an image shipped as  — an empty alt. Both invisible in the source, both a one-line fix once the audit points at them.
Bonus: reproduce cryptic build errors before you trust them
Adjacent habit from the same instinct. When a build throws something cryptic — like the chunkToString error I hit migrating this site to Astro 6 — I reproduce it in a throwaway git worktree (an isolated checkout that shares the repo but not the working tree) before I act on it. It isolates the change, confirms cause and effect, and lets me capture the exact error text instead of a paraphrase. Same principle as everything above: verify against reality, don’t trust the story in your head.
The checklist, in one place
Run after bun run build, before you merge:
- Rendered
langmatches the post’s language — grep the built<html lang>, don’t trust frontmatter - Canonical self-references the correct URL (no hardcoded typo pointing at a 404)
- hreflang alternates all resolve; single-language content self-references only
- No dead internal links — run the
dist/crawler - No cross-language leaks in “related” / “latest” widgets
- JSON-LD parses and has its required fields
- a11y — exactly one
<h1>,alton every<img>,langpresent - (bonus) reproduce any cryptic build error in a throwaway
git worktree
This is CI for a solo static blog
None of this is a test framework. There are no fixtures, no test runner, no mocking — it’s grep and one small Node script that reads files, run after bun run build and before the merge. Total time: under a minute. For a solo blog that deploys straight to production, that minute is your CI, and it’s aimed exactly where the risk is: not at your writing, which you already re-read, but at the generated HTML you never see.
The through-line with the rest of what I’ve written about this stack — the analytics that silently died, the version bump that broke the vendored theme — is that the expensive bugs are the ones that throw no error. Publishing without looking at the build is trusting that every layer between your Markdown and the CDN did exactly what you assumed. Sixty seconds of grep says whether it did.