Skip to content

·  · 8 min read

How I Validate an Astro Blog Post Before It Goes Live

Publishing a static Astro post is one merge away from live — which is exactly why the silent bugs are dangerous. A wrong lang, a canonical that 404s, a leaked cross-language link: none of them show up in your editor, only in the built HTML. Here's the checklist I run on dist/ before every post, scripts included.

Publishing a static Astro post is one merge away from live — which is exactly why the silent bugs are dangerous. A wrong lang, a canonical that 404s, a leaked cross-language link: none of them show up in your editor, only in the built HTML. Here's the checklist I run on dist/ before every post, scripts included.

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 deploy

Everything 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 200

For 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.)

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.html

The 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 lang matches 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>, alt on every <img>, lang present
  • (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.

    Share:

    Enjoyed this post?

    Get new posts on web dev, AI and SEO straight to your inbox. No spam, unsubscribe anytime.

    No spam. By subscribing you agree to the privacy policy .

    Back to blog

    Related Posts

    View All Posts »
    How I Cut My Homepage Image Weight 88% (Astro + Sharp + WebP)

    How I Cut My Homepage Image Weight 88% (Astro + Sharp + WebP)

    A static Astro site with 15KB of JavaScript was shipping 3.4MB of images. Here are the three failure modes that got past the image pipeline — the fallback-always-ships rule, the screenshot the optimizer never touched, and the boilerplate nobody referenced — and the 15 lines of Sharp that fixed all of it.