If your site is built by a static site generator, you have a huge advantage over a CMS: every page exists as a file at build time. That means broken internal links can be caught before anything is deployed.
Many generators have this built in or available as a small plugin, and a lot of teams never switch it on. Here's how to do it in six popular ones, plus a generic fallback that works with any of them.
A quick note on scope: these mostly check internal links (pages on your own site). External links change independently of your builds, so they need a different approach, covered at the end.
1. Docusaurus: built in, just make it strict
Docusaurus checks internal links during build by default. The question is what it does when it finds one. Make it fail:
// docusaurus.config.js
export default {
onBrokenLinks: "throw",
onBrokenAnchors: "throw",
onBrokenMarkdownLinks: "throw",
// ...
};
Depending on your Docusaurus version, some of these options may have moved or default to "warn". Check the docs for your version. Warnings in a build log are easy to scroll past; "throw" makes them impossible to ignore.
2. Hugo: ref and relref shortcodes
Hugo's ref and relref shortcodes resolve links to content files at build time. If the target doesn't exist, the build reports an error.
See the [installation guide]({{< relref "docs/install.md" >}}).
Make sure missing references are treated as errors:
# hugo.toml
refLinksErrorLevel = "ERROR"
The catch: this only covers links written with the shortcode. Plain Markdown links like [guide](/docs/install/) aren't checked. For those, use a post-build checker (section 7).
3. Jekyll: html-proofer
Jekyll itself doesn't validate links, but html-proofer is the long-standing companion tool. It checks the generated HTML for broken internal links, missing images and more.
bundle exec jekyll build
bundle exec htmlproofer ./_site --disable-external
Start with --disable-external so builds stay fast and deterministic. html-proofer isn't Jekyll-specific; it works on any folder of HTML files.
4. MkDocs: validation settings
Recent MkDocs versions can validate links between Markdown pages and report problems at build time. Combine that with strict mode, so warnings fail the build:
# mkdocs.yml
strict: true
validation:
links:
not_found: warn
absolute_links: info
unrecognized_links: info
With strict: true, warnings become errors. Run mkdocs build --strict in CI if you prefer to keep the config relaxed locally.
5. Eleventy: no built-in check, add one after build
Eleventy deliberately stays minimal, so link checking happens on the output. A simple approach is to run a checker against the _site folder (see section 7), or start a local server and crawl it:
npx @11ty/eleventy
npx linkinator ./_site --recurse --skip "^(?!http://localhost)"
linkinator can serve a local directory directly, which keeps the check self-contained.
6. Astro: integrations or post-build
Astro doesn't fail builds on broken links by default. Community integrations exist that check links during build. Look for one that is maintained and matches your Astro version. Otherwise, the post-build approach works the same as for Eleventy:
npm run build
npx linkinator ./dist --recurse
7. The generic fallback: check the built HTML
Whatever generator you use, the output is a folder of HTML files. Check that folder after the build and before the deploy. lychee is fast and works offline for local files:
lychee --offline --no-progress './public/**/*.html'
--offline skips external URLs, so the check only fails for things your build controls.
And in CI:
# .github/workflows/links.yml
on: [pull_request]
jobs:
internal-links:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- uses: lycheeverse/lychee-action@v2
with:
args: --offline --no-progress './dist/**/*.html'
fail: true
What about external links?
External links shouldn't fail your build, for two reasons:
- They break without you changing anything. A build that failed yesterday because someone else's site was down will fail again today for no reason related to your change.
- They're slow and flaky in CI. Rate limits, timeouts and bot-blocking produce false failures.
Better options for external links:
- A scheduled job (weekly, for example) that checks external links and opens an issue with the results.
- A monitoring service that crawls your live site and reports broken outbound links over time. That's what linkbruno.com does, and it also catches problems that appear on the live site without a deploy.
A sensible setup
| Check | When | Fails the build? |
|---|---|---|
| Internal links and anchors | Every build | Yes |
| Missing images and assets | Every build | Yes |
| External links | Weekly, or continuous monitoring | No, report instead |
Rolling it out without blocking everyone
If you switch on strict checking on an older site, the first build will probably fail with dozens of errors. Don't let that stop you:
- Run the check in report-only mode first and fix the backlog in one or two focused pull requests.
- Once the count is zero, switch to fail on error.
- Add the check to your pull request template or contributing guide, so new writers know why their build failed.
A clean baseline is what makes the check useful. Ten known failures that everyone ignores hide the eleventh.
Common gotchas
-
Base paths. If your site is served under
/docs/, a checker run on the output folder may need to know that, or every link looks broken. -
Trailing slashes.
/aboutvs/about/can resolve differently depending on your host. Match your checker's settings to how your host serves pages. -
Generated redirects. If your host handles redirects (like a
_redirectsfile), a local checker won't know about them. Exclude those paths or test against a preview deploy.
Related reading
- Your link checker's timeout is lying to you: a local benchmark of false positives
- Should a broken link fail your CI build? Block internal links, warn on external ones
- Check links in Markdown docs with a 60-line Python script
- '0 broken links found' is a myth: 7 places link checkers don't look
Which generator do you use, and does your build fail on broken internal links today? If not, what's stopped you? Tell me in the comments.
Top comments (0)