- Oct 4, 2026
- 4 min read
A Missing 404 Page Hid 273 Broken Links
My ebooks library passed the link check with a perfect 100. Then I added a 404 page, and the next crawl found 273 broken links.
The links hadn’t changed. The 404 page just made them visible.
Soft 404s on Cloudflare Pages
When a static site on Cloudflare Pages has no top-level 404.html, Pages
treats it as a single-page app: every path that doesn’t match a file is
answered with the homepage and status 200.
$ curl -s -o /dev/null -w "%{http_code}\n" https://ebooks.jalcocertech.com/does-not-exist
200 # before: the library page
404 # after adding src/pages/404.astro
That has three effects, none of them visible in a browser:
- Link checkers report nothing. A link to a page that doesn’t exist returns 200 and some HTML, so it counts as fine.
- Search engines see duplicate pages. Every mistyped URL is a copy of the homepage, a “soft 404”.
- Files you think you have, you don’t. My audit flagged the ebooks
robots.txtas odd. It didn’t exist; Pages was serving the library page at/robots.txt.
The fix for all three is one file. In Astro, src/pages/404.astro builds to
dist/404.html, and Pages serves it with a real 404 status.
What the 404 page uncovered
Almost all of the 273 broken links came from one pattern. The chapters are plain Markdown that also reads well on GitHub, so they link to each other the GitHub way:
See [the meetings chapter](./dna-effective-meetings.md) for the template.
Run [the solver](../2D-Kinematics/solver.py) to reproduce the plot.
On GitHub those work. On the site, a chapter lives at
/books/<book>/<chapter>/, so the first link resolved to
/books/<book>/<chapter>/dna-effective-meetings.md, and the second pointed
at a script that was never published.
Fixing it at build time
I didn’t want to rewrite the chapters. They’re meant to work on GitHub too. A remark plugin rewrites the links while Astro builds the site:
- a link to a
.mdfile that is a chapter of the same book becomes../<chapter>/, keeping any#anchor; - a link to any other relative file becomes plain text, because that file isn’t on the site;
- images, absolute URLs, root paths and
#anchorsare left alone.
function rewrite(node, parent, index, fileDir, bookDir, chapters) {
if (node.type === 'link') {
const url = node.url || '';
if (url && !/^([a-z][a-z0-9+.-]*:|\/|#)/i.test(url)) {
const [target, hash] = url.split('#');
const rel = path.relative(bookDir, path.resolve(fileDir, decodeURI(target))).replace(/\.md$/, '');
if (target.endsWith('.md') && chapters.has(rel)) {
node.url = `../${rel}/${hash ? `#${hash}` : ''}`;
} else if (target) {
// Not published: keep the words, drop the link.
parent.children.splice(index, 1, ...node.children);
return node.children.length - 1;
}
}
}
// …recurse into children
}
The list of chapters comes from each book’s _metadata.json, which the site
already uses to order them. The plugin is registered next to the existing
ones:
markdown: {
remarkPlugins: [remarkMath, remarkChart, remarkChapterLinks],
},
Checking the result without a crawl
Re-crawling the live site takes minutes. Checking dist/ takes a second: for
every <a href> and every src in the built HTML, resolve it against the
page’s URL and see whether a file exists.
for f in glob.glob('dist/**/index.html', recursive=True):
page = '/' + os.path.relpath(os.path.dirname(f), 'dist') + '/'
for h in re.findall(r'<a [^>]*href="([^"]+)"', open(f).read()):
if re.match(r'^([a-z]+:|#|//)', h):
continue
p = 'dist' + urljoin(page, h).split('#')[0]
if not (os.path.exists(p) or os.path.exists(p + 'index.html')):
bad.append((page, h))
Result: 3,998 internal links and 1,044 image and script references, all resolving. The next crawl of the live site agreed: links 100, now measured against real 404s.
Takeaways
- No 404 page means no broken links can be detected. Add one before you trust any link report for a static site on Pages.
- Markdown written for GitHub needs translating for the web. Do it at build time so the source stays readable in both places.
- Check
dist/before you deploy. It’s faster than a crawl and catches the same class of mistake.
This came out of running my audit tool on my own sites; the full story is here.