Fixing Broken Links Reported by Docusaurus Builds

A Docusaurus build ends with a list like this, and on a large site the list is long:

[WARNING] Docusaurus found broken links!
Please check the pages of your site in the list below, and make sure you don't reference any path that does not exist.
- Broken link on source page path = /docs/2.x/guides/auth/:
   -> linking to ../configuration (resolved as: /docs/2.x/guides/configuration)
   -> linking to /docs/api/client#retry (resolved as: /docs/api/client)
- Broken link on source page path = /docs/next/migration/:
   -> linking to /docs/2.x/old-page

The report is accurate but not always intuitive: "resolved as" paths that look nothing like what the author meant, links that work in the browser but fail the check, and the same broken link reported once per version of the docs. This guide works through 214 broken links on a versioned Docusaurus 3.6 site with three doc versions and a blog, groups them by cause, fixes each group, and turns the warnings into build failures. It belongs to Docs Frameworks: Docusaurus, Starlight and VitePress.

Read the Report in Bulk

Run the build with the checks set to warn and capture the output:

npx docusaurus build 2>&1 | tee build.log
grep -c '\-> linking to' build.log

214 lines. Grouping them by the target and by the "resolved as" path shows patterns faster than reading them in order:

grep -oE 'linking to [^ ]+ \(resolved as: [^)]+\)' build.log | sort | uniq -c | sort -rn | head -30

Many links appeared three times — once in each docs version — so 214 reports came from 81 distinct broken links.

Number of distinct broken links grouped by cause Horizontal bars. Relative links without file extensions resolved against the wrong path 29. Links to anchors that no longer exist 21. Cross-version links to pages removed in newer versions 14. Links to generated or excluded pages 9. Absolute links missing the base URL 8. Distinct broken links by cause Relative URL without extension 29 Missing anchor 21 Cross-version link 14 Excluded or generated page 9 Missing baseUrl 8
214 reported links collapsed to 81 distinct problems; versioned docs repeat each one per version.

Cause 1: Relative URLs Instead of File Paths

[configuration](../configuration) is a URL, resolved relative to the current page's URL. Whether the current URL ends in a slash changes the result: from /docs/guides/auth/, ../configuration is /docs/guides/configuration; from /docs/guides/auth, it is /docs/configuration. The site had switched trailingSlash to true a year earlier, silently breaking 29 such links.

The fix is to link to the Markdown file, with its extension, relative to the current file:

See [configuration](../configuration.md).

Docusaurus resolves file links at build time to the target's final route, so they survive slug changes, trailing-slash settings and versioning. A codemod rewrote all 29: for each relative URL link, it looked for a file at the corresponding path with .md or .mdx and replaced the link when exactly one match existed.

Cause 2: Anchors That No Longer Exist

onBrokenAnchors (Docusaurus 3.1+) reported 21 links to headings that had been renamed. #retry had become #retrying-failed-requests when the heading was expanded. Two approaches fixed them: update the link to the new ID, or pin the heading's ID so it no longer depends on its text:

## Retrying failed requests {#retry}

Explicit IDs were added to the 40 most-linked headings across the site, found by counting anchor targets in the link report and in the search analytics. They now survive any rewording.

Three anchors were reported broken although they worked in the browser: they were created by a custom <ApiParam> component that rendered id attributes the link checker did not know about. Docusaurus's useBrokenLinks().collectAnchor(id) hook registers such anchors; adding one call to the component cleared them.

How Docusaurus collects anchors and checks links against them Four steps. Markdown headings register their IDs with the link checker automatically. A custom component renders an element with an id but does not register it. The link checker compares each link's anchor with the registered set and reports the component's anchor as broken. Calling collectAnchor in the component registers it and the report clears. Why a working anchor can be reported broken Headings register IDs automatic Component renders id not registered Checker compares reports as broken collectAnchor(id) report clears
The browser only needs the id attribute; the build-time checker needs the registration.

Older docs versions (1.x and 2.x) are snapshots. Pages in them linked to absolute paths such as /docs/2.x/old-page that had been renamed in later edits of the same version, or to /docs/api/client, which pointed at the current version's page and broke when that page moved. Two rules fixed these:

  • Inside a versioned doc, link with relative file paths, which Docusaurus resolves within the same version.
  • Never edit a removed page's URL into an old version; if a page is renamed in current, leave old versions linking to their own copy.

Fourteen links were rewritten to file paths. Because each was repeated across versions, that cleared 42 reports.

Nine links pointed at pages that existed as files but were not built: drafts (draft: true, excluded from production builds), files under a folder prefixed with _, and API pages generated by a plugin only in the current version. Docusaurus correctly reports them in production builds and not in docusaurus start, which is why authors had not noticed. The links were removed or pointed at published equivalents; the generated API pages were linked only from the current version.

The site deploys under /sdk/ on a shared domain, with baseUrl: '/sdk/'. Eight links in React pages were written as <a href="/docs/intro">, which skips the base URL. In JSX, <Link to="/docs/intro"> adds it automatically; plain <a> does not. Replacing them with Link fixed all eight. The same applies to useBaseUrl for images and static files.

Decision tree for fixing a Docusaurus broken link report The root asks what the reported link looks like. A relative URL without an extension should become a relative file path with md. A link with a missing anchor should be updated or the heading given an explicit ID. An absolute link into another docs version should become a relative file link within the version. A plain anchor tag in JSX missing the base URL should use the Link component. Choosing the fix from the report line What does the reported link look like? ../page (no extension) Link the file ../page.md /page#heading Fix the anchor or pin {#id} /docs/2.x/… in a version Relative file link stays in version <a href="/…"> in JSX Use Link adds baseUrl
Links to drafts and excluded pages, the remaining cause, are removed or redirected.

Make New Breaks Fail the Build

With the backlog cleared, the configuration changed from warning to failing:

// docusaurus.config.js
export default {
  onBrokenLinks: 'throw',
  onBrokenAnchors: 'throw',
  markdown: { hooks: { onBrokenMarkdownLinks: 'throw' } },
};

(Before Docusaurus 3.8, onBrokenMarkdownLinks is a top-level option.) External links are not checked by the build; a scheduled lychee job checks them weekly and opens an issue, as in Checking Links in Pull Requests, so a third-party outage never blocks a docs deploy.

Broken links come back if writers keep the habits that caused them. Two small changes in the authoring workflow kept the count at zero. The contributing guide's "linking" section now has one rule — link to the .md file, relative to the current file — with a before-and-after example, and the docs repository's pull request template includes a checkbox for it. More effective was editor support: the docs team uses VS Code, whose built-in Markdown language features complete file paths and flag links to files that do not exist. Enabling markdown.validate.enabled in the workspace settings shows broken file links as squiggles while typing, long before the build runs, and caught most new problems in the first weeks.

Measured Impact

MeasureBeforeAfterTool
Broken link reports2140Docusaurus build
Distinct broken links810Grouped report
Headings with explicit IDs343grep {#
Build failures from new broken links, 8 weeks—11, all fixed in PRCI history
404s from internal referrers (edge logs)1,960 / week140 / weekEdge 404 log

The last row is the reader-facing result: internal 404s fell by 93%. The remaining 140 per week came from old cached pages and external sites, tracked as in Logging 404s at the Edge.

Pitfalls & Rollback

  • Linking with URLs instead of file paths. File paths survive slug, trailing-slash and version changes.
  • Renaming headings that other pages link to. Pin IDs on frequently linked headings.
  • Absolute links inside versioned docs. Use relative file links so each version is self-contained.
  • Checking external links in the build. It makes builds flaky; schedule it instead.
  • Rollback: setting the three options back to warn restores the old behaviour without undoing any link fixes.

Conclusion

Docusaurus's broken link report is accurate; the work is in reading it in bulk and fixing causes rather than lines. On a three-version site, 214 reports came from 81 links and five causes — relative URLs, renamed anchors, cross-version links, unbuilt pages and missing base URLs — and setting the checks to throw has kept the count at zero since.

FAQ

Usually because the link resolves to a different URL than expected. Relative links without a file extension are resolved against the current URL, so a link that works on a page with a trailing slash breaks on the same page without one. Linking to the Markdown file path with its extension lets Docusaurus resolve it to the correct route regardless.

onBrokenLinks checks links in the final rendered pages against the set of generated routes. onBrokenMarkdownLinks checks links written as file paths in Markdown that do not point to an existing file. Both can be set to throw, warn, log or ignore. A link can pass the Markdown check and still fail the route check, for example when it points to a file excluded from the build.

Yes, since Docusaurus 3.1, through onBrokenAnchors. It reports links to a heading ID that does not exist on the target page. Headings with custom IDs and anchors created by components need the component to register the anchor, otherwise they are reported as broken even though they work in the browser.

Yes, for internal links. Set onBrokenLinks and onBrokenAnchors to throw so a pull request cannot introduce a broken internal link. External links should be checked separately on a schedule, because their failures are outside your control and would make builds flaky.

Filed underFixesDocusaurus