I counted 46,382 internal links across 1,334 built HTML pages. 24,948 of them pointed at a URL that answers with a 301. That's more than half.
The embarrassing part is that I wasn't looking for this. I was measuring link-text accessibility. There's a WCAG concern that says if two links share the same name but go to different places, a screen reader user can't tell them apart, and I wanted a machine count of it. The script flagged 1,330 pages. When I opened them up, none of it was an accessibility defect. A link named home went to /en/ in the header and /en in the footer.
A trailing slash isn't a style preference, it's a second URL
Start with the ground floor. https://example.com/blog and https://example.com/blog/ look like the same page to a human, but over HTTP they are two distinct resource identifiers. Which one hands back the actual document is the server's call. Static hosts usually lay files out as directories (/blog/index.html), so the slashed form returns 200 and the slash-less form 301s over to it. Some hosts do the reverse. It's a convention, not a rule, and conventions vary per deploy target.
The question that matters isn't which form is right. It's what happens when both forms appear inside one site: the site starts advertising its own pages under two addresses. The canonical tag names one, half the internal links name the other. That's the state my site was in.
Here's the actual response from my deployment:
$ curl -sS -o /dev/null -w "%{http_code} -> %{redirect_url}\n" https://jangwook.net/en
301 -> https://jangwook.net/en/
$ curl -sS -o /dev/null -w "%{http_code}\n" https://jangwook.net/en/
200
Browsers follow that 301 silently, so no user ever notices. The page renders the same. It just takes two requests to get there.
The accessibility metric that caught a URL bug
The original job was a link-text audit. For every built page, pull each <a> element's accessible name (aria-label, then text, then image alt, then title) and count the cases where one name inside a single page resolves to two or more destinations.
The hygiene numbers that fell out along the way were fine. Zero internal links with an empty name. Zero instances of hollow link text like "here", "read more", or "click here". Zero image links missing alt. 7,153 distinct link names in total.
But the "same name, different destination" check fired on 1,330 pages, which is effectively all of them. Looking at the top offenders, two different things were tangled together.
The first was a false positive. The language switcher carries the same name on every page, πΊπΈ English, while its destination changes per page. One name with 323 destinations attached reads as a violation to a machine, and as completely normal behavior to a person. WCAG judges link purpose from the text together with its context, and for a language switcher the current page is the context, so this rule was never meant to catch it. If you build an automated metric and don't separate out structural false positives like this one first, the whole list becomes noise.
The second was real. home, blog, about, contact, social. Five names, two destinations each, and the only difference between the pair was one trailing character. A defect that belongs to URL normalization showed up in an accessibility report first.
One line of source explained it:
<!-- src/components/Header.astro -->
<a href={`/${lang}/`}>{t("nav.home")}</a>
<!-- src/components/Footer.astro -->
<a href={`/${lang}`}>{t("nav.home")}</a>
Two components written on different days, with nothing in between enforcing a shape. No automated check caught it either, because nothing was broken. This repo's build gate already asserts broken internal links: 0, and a 301 sails right through that assertion. The same thing happened when I measured crawl depth and confirmed zero unreachable pages. Reachable is reachable.
Breaking down 46,382 links
Full sweep, run against the 1,334 HTML files in dist/. Counted links are same-origin path links; external URLs, mailto:, tel:, bare anchors, and static files with extensions are excluded.
| Metric | Value |
|---|---|
| Built HTML pages | 1,334 |
| Internal path links | 46,382 |
| Links ending in a slash | 21,434 (46.2%) |
| Links without a slash | 24,948 (53.8%) |
| Of those, a real page exists at the slashed path, so a 301 is certain | 24,944 |
| Pages carrying at least one such link | 1,330 of 1,334 |
<link rel="canonical"> in slashed form |
1,330 of 1,330 |
That last row is the whole argument. Every canonical tag on the site declared the slashed form without exception. So all 24,948 slash-less links pointed at an address the site itself had already declared non-canonical.
Splitting by where they were emitted assigns the blame:
| Location | Offending links |
|---|---|
| Templates (footer, header, and friends) | 10,640 |
Inside article body (article/main) |
14,300 |
| Other | 8 |
Body copy outnumbering templates stung. Those 14,300 are the contextual links I hand-write into each post plus the ones the related-posts component generates. Which means the harder I worked at internal linking, the more malformed links I shipped.
What the official docs say, and what they don't
Worth calibrating expectations precisely here. Two sentences are directly verifiable in Google's documentation.
When linking within your site, link to the canonical URL rather than a duplicate URL. Linking consistently to the URL that you consider to be canonical helps Google understand your preference.
(Consolidate duplicate URLs β Google Search Central)
Avoid long redirect chains, which have a negative effect on crawling.
(Large site owner's guide to managing your crawl budget β Google Search Central)
Both guarantee less than people assume. The first says aligning internal links with your canonical helps Google understand your preference. It does not say misalignment earns a penalty. The second is about long chains. Mine is a single hop.
And the crawl budget framing doesn't apply to me at all. The same document scopes itself:
Large sites (1 million+ unique pages) with content that changes moderately often (once a week)
Medium or larger sites (10,000+ unique pages) with very rapidly changing content (daily)
A 1,334-page site is neither. So writing "I fixed this to save crawl budget" would be a lie, and claiming a ranking lift would be worse. Google's position on structured data and on link shape is consistently that neither guarantees rankings, and I'm not stepping over that line.
So why fix it? Three reasons, all of them outside the ranking conversation.
First, user latency. I hit each URL seven times. The 301 response itself came back at a median of 33.6ms, actually faster than the document. The 200 that delivers the real page ran a median of 43.0ms. The catch is that a user pays both: roughly 77ms against 43ms. Seven samples from one laptop against a warm edge cache, so don't treat the absolutes as gospel. The direction is unambiguous, though. One trip beats two.
Second, removing a self-contradiction. Canonical says A while half your internal links say B, and there's no angle from which that's easy to defend.
Third, this doesn't stay contained to links. When a slash-less URL gets shared externally, your analytics splits the same page across two paths. Even with rankings entirely off the table, measurement quality alone justifies the fix.
Four stages from 24,948 to zero
It didn't go down in one pass. Fix, re-measure, look at what's left, find the next cause.
Stage 1. Templates (13 files, 29 lines). Footer.astro, AuthorBox.astro, HeroSection.astro, BlogPost.astro, and a handful of pages emitting /${lang}/blog-shaped hrefs got the slash. Result: 24,948 β 7,808. Twenty-nine lines removed 17,140 links, because one template line gets copied onto 1,330 pages. That's the longest lever on the board.
Stage 2. Markdown body normalization (1,276 files). The hand-written ](/en/blog/en/slug) links in post bodies, rewritten in bulk. Anchored links (...slug#section) need the slash inserted before the anchor, not after.
perl -pi -e 's{\]\((/[a-z]{2}/[^)\s#]*[^/)\s#])(#[^)\s]*)?\)}{"](" . $1 . "/" . ($2//"") . ")"}ge' "$f"
Result: 7,808 β 3,905.
Stage 3. The related-posts component (one line). Tracking the remaining 3,905 through a single rendered page put every one of them inside a recommendation-item block. The culprit was the line in RelatedPosts.astro that assembles a URL from a slug. Result: 3,905 β 85.
Stage 4. The last 85. Scattered across 11 pages, in three flavors. Raw HTML <a href="..."> anchors written inside Markdown in three old posts, a sourceReport field in the improvement-history JSON data, and a hardcoded link in 404.astro. The final stragglers always hide somewhere odd.
Four stages later, zero internal links hit a redirect. Strictly speaking four slash-less ones survive, but they are /research/seo/*.svelte paths referenced by an old post, which were never pages to begin with. That is a separate cleanup. What I find telling is the shape of the work: stage 1 handled 69% of the problem by touching 13 files, and the other three stages touched roughly 1,280 files to handle the remaining 31%. Hand-written links are expensive in exactly this way.
The audit script, ready to run
No browser, no headless tooling. Parse the build output and you're done. cheerio is the only dependency.
import fs from 'node:fs';
import path from 'node:path';
import * as cheerio from 'cheerio';
const DIST = process.argv[2] ?? 'dist';
const SITE = 'https://example.com';
function walk(dir, out = []) {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) walk(p, out);
else if (e.name.endsWith('.html')) out.push(p);
}
return out;
}
let total = 0;
const bad = [];
for (const file of walk(DIST)) {
const rel = '/' + path.relative(DIST, file).replace(/index\.html$/, '');
const $ = cheerio.load(fs.readFileSync(file, 'utf8'));
$('a[href]').each((_, a) => {
const href = $(a).attr('href');
if (!href || /^(https?:|mailto:|tel:|javascript:|#|\/\/)/i.test(href)) return;
const { pathname } = new URL(href, SITE + rel);
if (/\.[a-z0-9]{2,5}$/i.test(pathname)) return; // skip static files
total++;
// flip this condition if your canonical form has no trailing slash
if (!pathname.endsWith('/')) bad.push(`${rel} -> ${href}`);
});
}
console.log(`internal links: ${total}, non-canonical form: ${bad.length}`);
for (const b of bad.slice(0, 20)) console.log(' ' + b);
if (bad.length) process.exit(1);
Point it at dist. Grepping source misses every link a component assembles at build time, and in my case that was half of them.
Those last two lines are the CI gate: anything other than zero fails the build. If you have too many existing violations to turn the gate on, get to zero first and then gate. Setting a threshold and calling it "hold the line" guarantees the number climbs again. That measure-then-gate order is why I keep reusing it, most recently when I audited hreflang reciprocity.
Wrap-up: write links as the exact string your canonical uses
-
Find your canonical form first. Check whether
<link rel="canonical">includes the trailing slash. That's your baseline, and internal links match it. Aligning the other way is equally valid. Mixing is the only failure mode. - Audit the build output, not the source. Templates, components, data files, and Markdown each emit links. The final HTML is the only place they all meet.
- "Zero broken links" and "zero redirects" are different checks. A 301 is not a broken link, and your existing link checker will happily pass it.
- Start with templates. Thirteen files covered 69% of mine. Longest lever, shortest diff.
- Chase the stragglers all the way down. Raw HTML anchors, URL fields inside JSON data, the 404 page. The last few dozen are never where you expect.
- Reach zero, then gate. Twenty lines returning exit code 1 is enough to stop the regression.
- Don't turn this into a ranking claim. There's no evidence this fix lifts rankings. What you get is one round trip saved, a consistent canonical signal, and analytics that don't split.
Catching a URL bug while building an accessibility metric looks like luck, but it isn't really. A single link is a destination name to a person, a declaration of the canonical address to a crawler, and an aggregation key to an analytics pipeline. Audit through one lens only and defects in the other two stay in shadow.
Sweeping build output like this and turning "what's leaking" into a number is the work I do. If you're curious what that number looks like on a site you're running, the contact paths are on my profile.
Sources: Google Search Central's Consolidate duplicate URLs and Large site owner's guide to managing your crawl budget, both official. Measurement setup: 1,334 HTML files from my own Astro build output, parsed exhaustively with Node 24 and cheerio 1.2.0; status codes and latency from 7 curl samples. The link counts and timings come from this site on this deployment, and are not statements about how Google processes anything.

Top comments (0)