Skip to content
Blume
Esc
↑↓navigate↵open⌘Jpreview
Guides

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 11 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:

SeverityWhat it meansExamples
ErrorSomething 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
WarningAdvisory. 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-docs

Replace 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 its seo.canonical still says so. Since /setup now 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-started redirects to /setup, which redirects again.
  • A broken redirect. /plans points 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 audit

The 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:

CommandExits non-zero on
blume auditErrors (the default, same as --fail-on error)
blume audit --fail-on warning, or --strictErrors and warnings
blume audit --fail-on infoAny 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_chain

Both 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.xml

If 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 warning

Every 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.example

It 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 audit
Read the audit reference

A step here not working for you? Report a broken step.

Keep going.More guides.

Upgrade your docs with Blume.

Install today and ship a production-grade docs site in minutes. Free and open source, forever.

npx blume init