Internal Link Rot: Articles Point to Renamed or Deleted Slugs

Half your internal links return 404 because you renamed slugs without redirects. Run linkinator or lychee in CI, add a redirects file, fail prebuild on dangling links.

A user clicks “Related: GPT Tips” at the bottom of your article and lands on a 404. You renamed that slug six months ago to chatgpt-tips but never went back to update the 47 articles that linked to the old gpt-tips URL. Search Console quietly logs the 404s. Google starts dropping the linking pages from the index because they leak into broken neighborhoods. Your internal PageRank graph has rotted from the inside.

Internal link rot is silent. The build does not fail, the page renders fine, and the broken link sits at the bottom of the article where most readers never scroll. But every dangling link is a leaked authority signal and a worse user experience. The fix is: run a link checker in CI (linkinator or lychee), maintain a redirects.json for renamed slugs, and fail the prebuild when any internal link points at a slug that does not resolve.

Common causes

1. You renamed a slug and never updated linkers

You changed gpt-tips.mdx to chatgpt-tips.mdx because it ranks better. Every article that linked to /en/articles/gpt-tips/ now 404s. No build error, no redirect, no warning.

How to spot it: grep your content for the old slug.

grep -r "/articles/gpt-tips/" src/content/articles/ | wc -l

2. You deleted an article without checking inbound links

You deprecated a thin article. Forty-three other articles still link to it. Those links now 404.

How to spot it: before deletion, run an inbound-link scan:

grep -r "/articles/SLUG-TO-DELETE/" src/content/articles/

If the count is nonzero, you must either update or redirect.

You wrote /en/articles/chatgpt-tipss/ (typo: double s). The page renders the link, the build does not validate it, the user clicks and 404s.

How to spot it: only a real link checker catches this. Regex against frontmatter urlSlug values gives a definitive list of valid slugs.

Less critical for SEO but still bad UX. You linked to https://example.com/great-article/ and they restructured. Now it 302s to a homepage or 404s.

How to spot it: linkinator or lychee with --check-external.

You link to /en/articles/foo/#step-3. The article got rewritten and ## Step 3 is now ## Step 3: Verify. Slug for anchor changed (step-3-verify). Anchor 404s silently — the page loads but jumps to top.

How to spot it: anchor checkers are rarer; lychee with --include-fragments catches these.

Shortest path to fix

Step 1: Run lychee or linkinator across the built site

Install and run lychee:

# https://github.com/lycheeverse/lychee
brew install lychee
npm run build
lychee --offline --include-fragments dist/**/*.html > link-report.txt

Or linkinator for a JS-native option:

npx linkinator dist --recurse --silent --skip "^https://(facebook|twitter)" > link-report.txt

Output is a list of every dangling URL on the site. Sort by count of inbound references to prioritize.

Step 2: Build a redirects file for renamed slugs

If you renamed gpt-tips to chatgpt-tips, the right answer is a permanent 301, not a content rewrite of every linker. Maintain public/_redirects (Netlify/Cloudflare) or astro.config.mjs redirects:

// astro.config.mjs
export default defineConfig({
  redirects: {
    '/en/articles/gpt-tips/': '/en/articles/chatgpt-tips/',
    '/zh/articles/gpt-tips/': '/zh/articles/chatgpt-tips/',
  },
});

301 preserves authority. Once the redirect is live, the dangling links still work and Google forwards link equity. You can then fix the link text at leisure (or not at all).

A redirect catches renames; a prebuild check catches typos and deletions. Build a slug index from frontmatter, then validate every internal link in body:

# scripts/check-internal-links.mjs
import fs from "node:fs";
import path from "node:path";
import matter from "gray-matter";

const validSlugs = new Set();
const articleDirs = [
  "src/content/articles/en/troubleshooting",
  "src/content/articles/zh/troubleshooting",
];
for (const dir of articleDirs) {
  for (const f of fs.readdirSync(dir)) {
    const { data } = matter(fs.readFileSync(path.join(dir, f), "utf8"));
    if (data.urlSlug) validSlugs.add(`/${data.lang}/articles/${data.urlSlug}/`);
  }
}

let broken = 0;
for (const dir of articleDirs) {
  for (const f of fs.readdirSync(dir)) {
    const txt = fs.readFileSync(path.join(dir, f), "utf8");
    const links = [...txt.matchAll(/\(\/(?:en|zh)\/articles\/([^)#]+)\/?\)/g)];
    for (const m of links) {
      const url = `/${m[0].split("/")[1]}/articles/${m[1]}/`;
      if (!validSlugs.has(url)) {
        console.error(`BROKEN in ${f}: ${url}`);
        broken++;
      }
    }
  }
}
process.exit(broken > 0 ? 1 : 0);

Wire into prebuild so PRs cannot land dangling internal links.

Step 4: Fix the existing rot in batches

For each broken target:

- If renamed: add redirect, no content edit needed
- If deleted but content still valuable: undelete or restore from git
- If deleted intentionally: bulk-update linkers via grep-and-sed to point elsewhere or strip the link

Do NOT leave 404s sitting. Either redirect or rewrite.

Step 5: Submit updated sitemap and refetch

After fixing, resubmit the sitemap in Search Console and request indexing on the worst affected pages so Google re-crawls and sees clean neighborhoods.

Prevention

  • lychee or linkinator runs in CI on every PR; fails on dangling internal links
  • Internal-link prebuild validator against frontmatter slug index
  • Renaming a slug requires adding a redirects entry in the same PR — lint rule enforces it
  • Deletion checklist: scan inbound links first, redirect or rewrite before merging
  • External-link check runs weekly (not per-PR; too flaky) with a allow-fail tolerance
  • Quarterly review of redirects map; collapse chains (A -> B -> C becomes A -> C)

Tags: #Content ops #Site quality #Site audit #Troubleshooting #broken-link