On the client site, this list was the most useful part of the internal linking audit. Once the orphan pages were listed by URL, fixing them was just a matter of adding links by hand for a week, and the count went from 47 to 9. The last 9 were thin old pages, so I merged or deleted them. That was one site, and not a controlled experiment, so treat it as an anecdote. The main takeaway is that programmatic internal linking makes the problem visible, and visibility is most of the fix.
3. Rank pages by internal authority and find link opportunities
Finding orphans is the floor. The next question is which important pages are weakly linked, and which pages should link to them.
You can write PageRank yourself (it's about 20 lines), or use a package that does it. I used @power-seo/links here because it covers graph building, equity scoring, and suggestions with no dependencies. This is based on its documented API, so check the README for your installed version.
npm i @power-seo/links
// equity.mjs
import { readFileSync } from 'node:fs';
import { buildLinkGraph, analyzeLinkEquity, suggestLinks } from '@power-seo/links';
const { pages } = JSON.parse(readFileSync('pages.json', 'utf8'));
const okPages = pages.filter((p) => p.status === 200);
// 1. Who has authority?
const graph = buildLinkGraph(okPages.map(({ url, links }) => ({ url, links })));
const scores = analyzeLinkEquity(graph, { damping: 0.85, iterations: 20 });
console.log('Weakest pages (fewest inbound/lowest equity):');
scores
.sort((a, b) => a.score - b.score)
.slice(0, 10)
.forEach(({ url, score, inboundCount }) =>
console.log(` ${score.toFixed(4)} inbound=${inboundCount} ${url}`)
);
// 2. Who should link to whom?
const suggestions = suggestLinks(
okPages.map(({ url, title, content }) => ({ url, title, content })),
{ maxSuggestions: 3, minRelevance: 0.15 }
);
console.log('\nLink suggestions:');
suggestions.forEach(({ from, to, anchorText, relevanceScore }) =>
console.log(` ${from} -> ${to} "${anchorText}" (${relevanceScore.toFixed(2)})`)
);
Two caveats from using it:
- The equity score is a prioritization tool: It isn't Google's ranking signal. Use it to decide what to fix first, not as a prediction.
- Suggestions are word-overlap based, not semantic: At the default relevance threshold of 0.1 I got a lot of noise from shared common words. Raising it to 0.15 made roughly two out of three suggestions worth adding. Read every one before you add a link.
If you want to see the same workflow written up in more detail, I covered it in this longer post.
4. Stop orphans from coming back
A one-time cleanup decays. Every new post is a chance to create a new orphan, so run the check on every deploy. The process.exit(1) in analyze.mjs already makes it CI-friendly:
# .github/workflows/link-audit.yml
name: internal-link-audit
on:
schedule:
- cron: '0 6 * * 1' # Mondays
workflow_dispatch:
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: node crawl.mjs https://example.com
- run: node analyze.mjs
I'd run this against a staging URL on deploys and against production on a weekly schedule. The first time it failed on my own site, a post had gone live with no inbound links, and I would otherwise have found out weeks later.
What I Learned / Key Takeaways
- Seed your crawl with the sitemap: Crawling from the homepage alone can never find orphans, since they have no inbound links by definition.
- Normalize URLs before counting links: Trailing slashes, query strings, and fragments will give you phantom pages and wrong inbound counts.
- Treat automated suggestions as a first draft: Word-overlap matching finds candidates, but a human still decides what reads naturally.
- Make it a recurring check: Gaps come back whenever you publish, so a failing build beats an annual audit.
If you want to try this approach, here's the repo: https://github.com/CyberCraftBD/power-seo
Your Turn
What's the worst orphan page you've ever found on your own site, and how long had it been sitting there invisible? Also, do you check internal links in CI, or does it only happen when traffic drops? I'd like to hear what's worked for you.
Top comments (0)