// TL;DR
After three SEO defects that no build, lint or type check reported, I added a 116-line post-build gate. It walks the prerendered HTML in .next/server/app, collects every internal href, and fails the build if one points at a route the build did not emit. The design rule that makes it work: it reads the build output on both sides, never the data layer — deriving links and routes from the same source would only prove that source agrees with itself.
The August 2026 audit of this site turned up three defects in a row: a root-layout canonical that declared 90 pages duplicates of the homepage, a streaming loading shell that turned every unknown slug into a 200-status Not Found page, and a sitemap that nominated one of those pages for indexing.
They had one thing in common that bothered me more than the defects did. Not one of them was a bug in code that runs. Every function did exactly what it said. Each defect was a disagreement between two artifacts — a link and a route, a sitemap URL and a prerendered page — and disagreements have no runtime, so nothing in the toolchain has an opinion about them. The build passed. TypeScript passed. ESLint passed. So I wrote the thing that would have failed.
//What it actually compares
The gate builds two sets and subtracts one from the other. The first is every path the build can serve. It comes from walking .next/server/app and reading the filenames — .html and .body files are the prerendered pages and static route handlers:
const APP_DIR = '.next/server/app'
function validRoutes(): Set<string> {
const routes = new Set<string>(['/'])
for (const file of walk(APP_DIR)) {
if (!/\.(html|body)$/.test(file)) continue
const route = '/' + relative(APP_DIR, file).replace(/\.(html|body)$/, '')
if (route.startsWith('/_')) continue // _not-found, _global-error
routes.add(route === '/index' ? '/' : route)
}
return routes
}Files alone are not the whole picture, because some routes leave nothing behind. /github awaits searchParams, so it renders on demand and never writes an .html; those come from .next/app-path-routes-manifest.json. Bracketed patterns from that manifest are deliberately dropped — with dynamicParams = false on every [...id] route, a dynamic path that was not prerendered is exactly the thing I want flagged, not excused. The second set is every internal link the site actually rendered, scraped out of the same HTML files and normalised so cosmetic differences do not read as breakage:
function internalHrefs(html: string): string[] {
return [...html.matchAll(/href="(\/[^"]*)"/g)]
.map(m => m[1].replace(/[?#].*$/, ''))
.filter(h => h !== '' && !h.startsWith('//'))
.map(h => (h.length > 1 ? h.replace(/\/$/, '') : h))
}Anything in the second set that is not in the first is a broken link. The script keeps a map of broken target to the set of pages that link to it, so the failure names somewhere to go — the target alone would send me grepping. Static assets under public/ are skipped by a file-extension test, since they are served directly and leave no route behind.
//Why it reads the build output and not my data
The obvious implementation is shorter and wrong. You import skillsData, projectsData and the rest from common/data, generate the list of legal URLs, and check the links against it — milliseconds, no build required. It would also have missed the bug it exists for. The broken links were produced by components rendering slugify(skill.name); a checker built from common/data would have been checking my data against my data, and reported that my data is consistent — which was true, and irrelevant. The link said vue-js because a component said so. Only the rendered HTML knows that.
So both sides of the comparison are outputs, and neither is my belief about the site. What the components emitted, versus what the router emitted. That is the whole design, and the header comment in the file says so in one line, because it is the thing a future refactor will be most tempted to undo.
//Where it sits, and why it has to sit there
"build": "bun run icons:check && next build && bun run links:check"It runs after next build, and throws immediately if .next/server/app holds no prerendered HTML rather than passing an empty check — a checker that silently succeeds when it has nothing to look at is worse than no checker. It is plain TypeScript executed directly by Bun.js, so there is no build step for the build gate. The position has a cost I feel every time, though: feedback takes a full production build, so this can never be a pre-commit hook or an editor squiggle. I called that a fair price because the failure it prevents is silent and long-lived — a broken link ships, gets crawled, and sits there — but if my build ran ten minutes instead of a couple, I would be hunting for a cheaper approximation.
//What it does not catch
- Links that are not `href`s in prerendered HTML. My command palette navigates with a router call, not an anchor. One of the original broken call sites was exactly that — so the gate that was written for this bug would not have caught every instance of this bug.
- Client-only markup. Anything rendered after mount is invisible to a scraper of static output.
- External links. Out of scope on purpose: they fail for reasons I do not control, and a network call would make the build flaky.
- Hashes and query strings. Both are stripped before comparison, so
/skills/vue#usageis checked as/skills/vueand a dead anchor still passes. - Anything that looks like a file. The extension heuristic that skips
public/assets would also skip a route ending in a dot suffix.
That list is longer than the guarantee, and I would rather write it down than let the gate feel like proof. Passing links:check means no anchor in my prerendered HTML points at a route the build did not emit. It does not mean the site has no broken links.
Detail-route URLs must be keyed by
— scripts/links/check.ts, the text printed when the gate failsid— the same valuegenerateStaticParamsemits — neverslugify(name).
The failure message carries that sentence because a build error is the one piece of documentation that gets read at the exact moment it matters. Whoever hits this — me in a year, or an agent editing 🚀 Portfolio – Wongsaphat Puangsorn — will not have read the SEO section of my CLAUDE.md. They will have read a stack trace.
// What I'd Do Differently
- The bugs that survive longest in a solo project are not wrong functions, they are two artifacts quietly disagreeing. Type checks cannot see those, so a build gate has to compare two independent outputs.
- A checker built from the same source as the thing it checks always passes. If I cannot name the two different places my two sets come from, I have written a tautology with a progress bar.
- Writing down what a gate does not cover was the most useful part of building it. The list is longer than the guarantee, and knowing that stops me trusting a green build more than it deserves.
- I put the fix instruction in the error text, not just in the repo docs. Documentation people search for gets skipped; documentation that appears in a failing build gets read.
FAQ
5Q1 //How do I check for broken internal links in a Next.js build?
After next build, walk .next/server/app for the prerendered .html files, extract every internal href, and compare it against the set of routes the build emitted — the .html/.body filenames plus the on-demand routes in .next/app-path-routes-manifest.json. Fail the process when a link has no matching route. Wire it into the build script so it cannot be skipped.
Q2 //Why check the rendered HTML instead of my route definitions?
Because broken links are usually produced by components, not by your data. If the checker derives both the links and the valid routes from the same data source, it only proves that source is internally consistent — which was true in my case while the site was still shipping links to a page that did not exist.
Q3 //Can a build-time link checker catch client-side navigation?
No. Anything navigated with a router call rather than an anchor, or rendered only after mount, leaves no href in the prerendered HTML and is invisible to this kind of gate. It is a real gap, not a rounding error — one of the call sites that caused my original bug was exactly that shape.
Q4 //Should a link check run in lint, in a pre-commit hook, or after the build?
After the build, if you want it to compare against what the build actually emitted. That is the tradeoff: you get real feedback but only at production-build speed, so it cannot be an editor hint or a fast pre-commit hook. Make it fail loudly when there is no build output to inspect, so it never passes vacuously.
Q5 //Is a custom link checker worth it on a small site?
It was for me at roughly 90 routes, because the failure it prevents is invisible: a broken internal link ships, gets crawled, and stays wrong until a human clicks it. The cost is real though — a script to maintain, and a build that now needs its output. On a site where every link is hand-written and reviewed, the odds change.