research/an-internal-link-checker-for-a-static-build
An Internal Link Checker for a Static Build in 34 Lines of Node.js
Engineering Note · November 5, 2026 · 5 minute read · Node.js · Testing · Astro
Yes, a short script can check every internal link in a static build. Ours is 34 lines of Node.js with no dependencies. It reads the HTML files in the build folder, pulls out each link that starts with a slash, and checks whether the matching file exists. On our own 202-page Astro build it checked 8,766 links in about a quarter of a second and found none broken. Then we broke the site on purpose, and it found what we broke.
Everything below was run with Node.js 22.23.4 and Astro 7.3.8 on Linux. The outputs are the real ones from this run.
What does the script need to do?
A static site is a folder of files. A link is good if a visitor clicking it lands on one of those files. Three rules cover almost everything on a site like ours.
- A link to
/pricingis good if the folder containspricing.html,pricingitself as a file, orpricing/index.html. Hosts differ on which they serve, so we accept all three. - Anything after
#or?is ignored, and percent-encoded characters are decoded before looking up the file. - Only links that begin with a single slash are checked. Links to other sites, and protocol-relative links that begin with two slashes, are skipped.
What does the code look like?
import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
const dist = process.argv[2] ?? 'dist';
function* walk(dir) {
for (const name of readdirSync(dir)) {
const p = join(dir, name);
if (statSync(p).isDirectory()) yield* walk(p);
else yield p;
}
}
function resolves(path) {
const clean = decodeURIComponent(path.split('#')[0].split('?')[0]);
const base = join(dist, clean);
return [base, base + '.html', join(base, 'index.html')]
.some((c) => existsSync(c) && statSync(c).isFile());
}
const HREF = /\s(?:href|src)="(\/[^"/][^"]*|\/)"/g; // root-relative only, not //cdn
let pages = 0, links = 0;
const broken = [];
for (const file of walk(dist)) {
if (!file.endsWith('.html')) continue;
pages++;
for (const m of readFileSync(file, 'utf8').matchAll(HREF)) {
links++;
if (!resolves(m[1])) broken.push(`${relative(dist, file)} -> ${m[1]}`);
}
}
console.log(`${pages} pages, ${links} internal links, ${broken.length} broken`);
for (const b of [...new Set(broken)].slice(0, 15)) console.log(' ' + b);
process.exit(broken.length ? 1 : 0);
The pattern matches both href and src, so it also catches missing images, scripts and stylesheets that are served from our own site. The final line sets the exit status, which is what a build pipeline reads.
What did it find on a real build?
We built the site with SHOW_SCHEDULED=1 npx astro build, which produced 202 pages, then ran the script against the dist folder.
202 pages, 8766 internal links, 0 broken
That took 0.24 seconds of wall-clock time, and the exit status was 0. A clean result proves little by itself, because a checker that finds nothing looks the same as a checker that checks nothing. So we tested it on a deliberately broken copy.
Does it catch problems when they exist?
We copied the build folder and made three changes:
- Deleted
pricing.html. - Added a link to
/research/no-such-poston the About page. - Added a link to
/audit#nope(a real page, a made-up anchor) and a link to//cdn.example.com/xon the home page.
Here is the output, with the 367 /pricing lines removed:
201 pages, 8713 internal links, 368 broken
about.html -> /research/no-such-post
The exit status was 1. The totals show the useful lesson. Removing one page produced 367 broken links, because the pricing link sits in menus and footers that appear on nearly every page. The one invented link in the About page is easy to miss in that pile, so the full report is worth reading, and our script prints the first 15 unique lines by default.
The two controls behaved as designed. The link to /audit#nope was not reported, because /audit exists and the script ignores anchors. The //cdn.example.com/x link was not reported either, because it is not a root-relative link on our site. Both of those are limits, not bugs, and we list them below.
How do we use it in a pipeline?
Run it right after the build and let the exit status decide.
npm run build && node scripts/check-links.mjs dist
If the script exits with 1, the && stops the chain, and most CI services mark the step as failed. We have not wired this script into the GVX Digital pipeline in this note. The command above shows the shape, and the exit status behavior is what we tested.
What should you watch for when you adapt it?
- Trailing slashes. Some hosts add or strip them. We did not test links that end in a slash, so check what your host serves before relying on the result.
- Redirects. A link to a page that only exists as a redirect rule will be reported as broken, because there is no file. Our build has a
_redirectsfile, and none of our links pointed at a redirected address. If yours do, read the redirect file and treat its sources as valid. - Links in scripts. Addresses assembled in JavaScript at runtime never appear as
hrefattributes in the HTML and are not seen. - Base paths. If the site lives in a subfolder, strip that prefix before the file lookup.
- Case. Linux folders are case-sensitive, and some laptops are not. Run the check on the same kind of system your host uses.
Limitations
- The script checks that the target page exists. It does not check that an anchor such as
#pricing-tableexists on that page. - It does not check external links, relative links without a leading slash, or links built at runtime.
- It reads links with a regular expression, not an HTML parser. That is fine for HTML generated by a framework, where attributes are consistently quoted. Hand-written HTML with single quotes or unquoted attributes would be missed.
- We ran it on one Astro build with one set of deliberate breakages. We did not compare it against another link checker.
- Timing was from a single run on one machine. Treat 0.24 seconds as a rough order of magnitude.
Related reading
- Typed Content Collections in Astro for a Research Section
- A DST-Safe Weekday Cron in GitHub Actions: Two Schedules and a Guard
- Automating Accessibility Checks with axe-core and Playwright
Frequently asked questions
How do I check for broken internal links in a static site?
Build the site, read every HTML file in the output folder, pull out the links that start with a slash, and test whether each one maps to a file. A link to /pricing is good if the folder has pricing.html or pricing/index.html. Our 34-line version checked 8,766 links across 202 pages in 0.24 seconds.
Why check the built output instead of the source files?
The built output is what visitors get. Links are often assembled by templates, content collections and helper functions, so the source files do not show the final addresses. Checking the HTML after the build catches a link that only breaks once a template fills it in.
How do I make the check fail a deployment?
Have the script exit with a non-zero status when it finds a broken link, and run it as a step after the build. Ours exits with status 1 on any broken link and 0 on none, and most CI systems treat a non-zero exit as a failed step.
Does this catch broken anchors or external links?
No. It strips the part after a hash or question mark and only tests that the page exists. In our test a link to /audit#nope passed, because /audit exists. It also skips links to other sites, so a dead external link is not found. Those need a different check.
What are the common causes of broken internal links?
The usual ones are a page that was renamed or deleted while links to it remained, a typo in a slug, and a post that is scheduled or draft but still linked. Our test showed how one removed page can break hundreds of links at once, since a menu or footer link appears on every page.