Discoverability
Add a technical SEO check to your documentation build
A test project with planted SEO failures, an audit that points each one at its source line, the fixes, and a GitHub Actions job that blocks a bad deploy.
By Hayden Bleasel11 min read

To catch broken canonicals and missing metadata before you deploy, build your docs and run blume audit on the result. It reads the HTML in dist/ the way a crawler would and checks titles, descriptions, canonicals, social cards, the sitemap, robots.txt, and your redirects. Every finding names the source file and front matter line that fixes it, and the command exits non-zero at the severity you pick, so one CI step can stop a bad deploy.
In this guide you plant a stale canonical, a short description, a stale sitemap, and two bad redirects in a small project, watch the audit fail, fix each finding where it starts, and add the audit to GitHub Actions.
The audit only runs on a site Blume built, because it maps each page back to its source through Blume's route manifest. For another generator, use a general-purpose SEO crawler. It also isn't a model of how search engines rank pages: it doesn't measure Core Web Vitals, and it checks that structured data is well-formed, not that a page qualifies for rich results. To check the links in your Markdown before a build, blume validate is the lighter tool, and the link checking guide covers it.
What the audit reports
Each check has a severity, and the severity decides whether it can fail your build:
| Severity | What it means | Examples |
|---|---|---|
| Error | Something is broken. Fails the audit by default. | A canonical that points at a missing or redirecting page, a redirect that loops or lands nowhere, a sitemap URL the build doesn't serve |
| Warning | Advisory. Fails only when you ask it to. | A title or description outside its length range, duplicate titles, a redirect chain, an indexable page missing from the sitemap |
| Info (notes) | Worth a look, rarely worth failing a build. | Fewer than 50 words of prose, heading levels that skip, a page marked noindex |
Treat the length warnings as editorial prompts, not search rules. The audit wants titles between 10 and 60 display columns and descriptions between 110 and 160, ranges taken from SEO-crawler guidance. (A wide CJK character counts as two columns.) Google publishes no limit on description length: it truncates snippets to fit the device, and often builds the snippet from the page's content instead of the description. A length warning tells you to reread the text, nothing more. The check catalog lists every check with its severity and suggested fix.
Plant failures in a test project
Before you trust a gate to pass, watch it fail. Scaffold a small project for the fictional Acme docs at docs.acme.example:
npx blume init acme-docs --template docs --package-manager npm --yes
cd acme-docsReplace blume.config.ts with a config that sets the site URL and three redirects. The site URL matters: without it Blume can't write canonicals or a sitemap, so the audit has nothing to check there.
import { defineConfig } from "blume";
export default defineConfig({
title: "Acme Docs",
description:
"Guides and API reference for sending transactional email and SMS with Acme.",
deployment: {
site: "https://docs.acme.example",
},
redirects: [
{ from: "/setup", to: "/installation", status: 308 },
{ from: "/getting-started", to: "/setup", status: 308 },
{ from: "/plans", to: "/pricing", status: 308 },
],
});Replace the starter home page, and add two more pages beside it:
---
title: Introduction
description: Acme sends transactional email and SMS from your app. Start here, then read the installation and billing pages to set up and plan.
---
Acme is an API for sending transactional email and SMS: password resets, receipts, sign-in codes, and shipping updates. You send a message with one request, and Acme handles delivery, retries, and bounces for you.
New to Acme? The [installation](/installation) page sets up the SDK and your API key, and [billing](/billing) explains how messages are counted each month.---
title: Installation
description: Install the Acme SDK for Node.js or Python, set your API key as an environment variable, and confirm that your first request succeeds.
seo:
canonical: https://docs.acme.example/setup
---
Acme publishes SDKs for Node.js and Python. Both wrap the same REST API, so pick the one that matches your backend.
Install the package with your package manager, then set `ACME_API_KEY` in the environment your server runs in. The SDK reads the key from there, so it never appears in your source code. When the install is done, send a test message to your own address, then read [billing](/billing) to see how messages count toward your plan.---
title: Billing
description: How billing works.
---
Acme bills by the message. Every email and every SMS segment you send counts toward your monthly total, whether or not the recipient opens it. Messages that fail validation before sending are never counted.
When you go over the number of messages your plan includes, Acme keeps sending and bills the extra messages at the end of the month. You can download every past invoice from the dashboard.Last, add a sitemap left over from the site's previous generator:
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>https://docs.acme.example/</loc></url>
<url><loc>https://docs.acme.example/setup</loc></url>
<url><loc>https://docs.acme.example/plans</loc></url>
</urlset>Here's what's wrong, on purpose:
- A stale canonical. The installation page used to live at
/setup, and itsseo.canonicalstill says so. Since/setupnow redirects, the canonical sends search engines through a redirect back to the page itself. - A thin description. The billing page's description is 18 characters long.
- A redirect chain.
/getting-startedredirects to/setup, which redirects again. - A broken redirect.
/planspoints at/pricing, which doesn't exist. Billing lives at/billing. - A stale sitemap. Blume never overwrites a file you ship in
public/, so this copy replaces the sitemap Blume would generate, and it lists two URLs that now redirect.
Build and run the audit
The audit reads an existing build and never builds on its own, so build first:
npx blume build
npx blume auditThe build succeeds, because none of these mistakes break a page. The audit catches them:
blume audit 4 pages · dist · offline
312 audits · 4 errors · 3 warnings · 0 notes
indexability
✖ Canonical points to a broken or redirecting page 1 page
/installation docs/installation.mdx:5
fix: Point `seo.canonical` at a page that exists and doesn't redirect.
redirects
✖ Broken redirect 1 page
/plans blume.config.ts
fix: Point the redirect at a page that exists.
⚠ Redirect chain 1 page
/getting-started blume.config.ts
fix: Point every hop straight at the final destination.
sitemap
✖ Sitemap names a page that does not exist or redirects 2 pages
https://docs.acme.example/setup dist/sitemap.xml
https://docs.acme.example/plans dist/sitemap.xml
fix: Remove the URL from the sitemap, or build the page it names.
⚠ Indexable page not in sitemap 1 page
/billing docs/billing.mdx
fix: Remove `draft`/`hidden`/`noindex` from the page's frontmatter if it should be indexed.
content
⚠ Meta description too long or too short 1 page
/billing docs/billing.mdx:3
fix: Rewrite `description` in the frontmatter to fit the length range.
⊘ external skipped — pass --external (2 checks)
⊘ network skipped — pass --url <origin> (11 checks)The command exits with code 1, because there are errors. Four pages means the three docs pages plus the 404 page, and 312 audits is every check that ran times every page.
Read a finding
Findings are grouped by check, with the categories that hold errors first. Each line pairs the URL with the file to edit: docs/installation.mdx:5 is the canonical: line itself. Redirect findings point at blume.config.ts. Sitemap findings point at dist/sitemap.xml, the built copy, so the file to edit is public/sitemap.xml.
The report shows the first three pages per check. Add --verbose to list every page with the specific problem:
✖ Canonical points to a broken or redirecting page 1 page
/installation docs/installation.mdx:5
Canonical points at /setup, which is a redirect to /installation.
fix: Point `seo.canonical` at a page that exists and doesn't redirect.
redirects
✖ Broken redirect 1 page
/plans blume.config.ts
Redirect from /plans lands on /pricing, which the build does not serve.
fix: Point the redirect at a page that exists.
⚠ Redirect chain 1 page
/getting-started blume.config.ts
Redirect from /getting-started passes through 2 hops: /getting-started → /setup → /installation
fix: Point every hop straight at the final destination.The "not in sitemap" warning is a side effect of the stale file: its fix text assumes a draft, hidden, or noindex flag kept the page out, but here the hand-made sitemap never listed it.
Choose a severity gate
--fail-on sets the lowest severity that fails the run:
| Command | Exits non-zero on |
|---|---|
blume audit | Errors (the default, same as --fail-on error) |
blume audit --fail-on warning, or --strict | Errors and warnings |
blume audit --fail-on info | Any finding at all |
Start CI on the default. Errors are the findings that are wrong on every site: a canonical or sitemap entry that points through a redirect, a redirect to nowhere. Once you've cleared the warnings, move to --fail-on warning so new ones can't creep back in. If you fix only the errors in this project, the default audit passes with two warnings left, and --fail-on warning still fails.
Two flags help on a large site. --only narrows the report to checks or categories so you can work through one at a time, and --skip drops a check you've decided to accept:
npx blume audit --only indexability,sitemap
npx blume audit --fail-on warning --skip redirect_chainBoth take check ids (with or without the BLUME_AUDIT_ prefix) or category keys, comma-separated. A skipped check disappears from the report as well as the gate, so skip deliberately. A term that names no check fails the run with a suggestion, so a typo can't empty the report and pass. npx blume audit --list-checks prints every id.
Fix each finding at its source
Work down the report, editing the file each finding names.
The canonical. Delete the seo block from the installation page's front matter. Without it, Blume emits a canonical that points at the page's own URL, which is almost always what you want. The front matter becomes:
---
title: Installation
description: Install the Acme SDK for Node.js or Python, set your API key as an environment variable, and confirm that your first request succeeds.
---If a page really is a copy of another, redirect it to the page that stays rather than setting seo.canonical. Blume's generated sitemap still lists a page whose canonical points elsewhere, and the audit reports that as BLUME_AUDIT_NON_CANONICAL_IN_SITEMAP, an error.
The description. Write one that says what the page covers. It fills the meta description and the Open Graph and X card descriptions. A page with no description inherits the site's, so a site with many undescribed pages sees duplicate and length warnings rather than "missing" ones.
---
title: Billing
description: See how Acme counts messages toward your monthly bill, what happens when you go over your plan, and where to download past invoices.
---The redirects. Point every old URL straight at the page that exists now:
import { defineConfig } from "blume";
export default defineConfig({
title: "Acme Docs",
description:
"Guides and API reference for sending transactional email and SMS with Acme.",
deployment: {
site: "https://docs.acme.example",
},
redirects: [
{ from: "/setup", to: "/installation", status: 308 },
{ from: "/getting-started", to: "/installation", status: 308 },
{ from: "/plans", to: "/billing", status: 308 },
],
});The sitemap. Delete the hand-made file, and Blume generates one from your pages, leaving out drafts, hidden pages, and noindex pages:
rm public/sitemap.xmlIf you do need your own sitemap, keep it in step with every URL move. The audit checks it the same way. Then rebuild and audit at the stricter gate:
npx blume build
npx blume audit --fail-on warning blume audit 4 pages · dist · offline
312 audits · 0 errors · 0 warnings · 0 notes
✔ No issues found.
⊘ external skipped — pass --external (2 checks)
⊘ network skipped — pass --url <origin> (11 checks)It exits with code 0. On a real site with dozens of findings, npx blume audit --claude (or --codex) hands the full report to a coding agent. It's told to edit the file each finding names, then rebuild and audit until the report is clean, and you approve each edit through the agent's own prompts.
Add the audit to CI
The exit code is the whole contract, so the CI job is a build and an audit. Commit this workflow:
name: Docs audit
on:
pull_request:
push:
branches: [main]
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
- run: npm ci
- run: npx blume build
- run: npx blume audit --fail-on warningEvery pull request now builds the docs and fails if the audit finds an error or a warning, with the grouped report in the job log. Use npx blume audit without the flag if you're still working through warnings. To feed the findings to another tool, add --json: the report goes to stdout as JSON, complete even when the run fails, with each finding's file, line, code, and a docsUrl for its catalog entry.
Blume needs Node.js 22.12 or later, and npm ci needs the package-lock.json that blume init wrote, so commit both it and package.json.
Make sure CI knows the site URL
The canonical, sitemap, and social card checks need the absolute site URL in the build. This project sets deployment.site, so CI audits what production serves. If you leave it unset, Blume detects it on Vercel, Netlify, and Cloudflare Pages at build time, but a GitHub Actions runner is none of those. The build then has no canonicals or sitemap, and the audit reports that once instead of checking them: BLUME_AUDIT_SITE_NOT_SET (a warning) for a plain static config, or BLUME_AUDIT_SITE_INFERRED_AT_DEPLOY (a note) when you use a host adapter like vercel(). For the adapter case, the audit suggests building with that platform's variables set, like VERCEL=1 VERCEL_PROJECT_PRODUCTION_URL=<host>, rather than hardcoding a URL the platform already supplies.
Check a deployment too
Some problems only exist on the server: a rewrite that 404s a built page, responses without compression, or an X-Robots-Tag header quietly deindexing a page whose HTML looks fine. After a preview deploy, point the audit at it to add those network checks:
npx blume audit --url https://preview.docs.acme.exampleIt still reads the local build, so run it in a job that has built the same commit. Add --external to probe outbound links as well.
Troubleshooting
The audit says "No build found"
blume audit reads the output of the last build and never builds itself. Run npx blume build first, in the same job and working directory.
A fixed page still shows up in the report
The audit reads the built HTML, not your source files, so it reports the state of the last build. Rebuild after every fix before you audit again.
--only or --skip fails with "name no check or category"
Use the category key or a check id, not a heading from the docs: it's --only i18n, not --only Internationalization. The error suggests the closest match, and npx blume audit --list-checks prints them all.
No canonical or sitemap findings, ever
Look for BLUME_AUDIT_SITE_NOT_SET or BLUME_AUDIT_SITE_INFERRED_AT_DEPLOY in the report. Without a site URL the build writes no canonicals or sitemap, so there's nothing for those checks to find. Set the URL as described in Add the audit to CI above.
The build stops with BLUME_REDIRECT_MATCHES_PAGE
A pattern redirect covers a page you kept, and the build rejects it before the audit ever runs, since hosts disagree on which one answers. Narrow the pattern to the paths that actually moved. The URL migration guide covers planning a move.
"An unexpected error occurred" with "bad indentation of a mapping entry"
A front matter value contains a colon followed by a space, like description: Setup: install the SDK, which YAML reads as a nested key. Quote the value, or rephrase it without the colon.
The audit passes, but a page still isn't indexed
The offline audit only sees your HTML. A response header, a host rewrite, or a search engine's own choice of canonical can still keep a page out. Run the audit with --url against production, then work through troubleshooting pages that are not indexed.
Next step
Audit your own build
Build your docs and run the audit locally to see where you stand before you pick a gate for CI.
npx blume build && npx blume auditA step here not working for you? Report a broken step.