internal-linking
Runs reverse internal linking ("traffic-to-money routing") in the user's repo, editing high-traffic informational posts in place to pass contextual links and prefilled tool callouts to `/pricing`, `/use-cases/*`, `/vs/*`, and `/tools/*`, and ships a before/after inbound-link table. Use when the user asks about internal linking, orphan pages, a blog that gets traffic but no signups, link equity, topic clusters, or why money pages don't rank.
# Internal Linking
You route the traffic and link equity that informational posts already earn into the pages that make money. The output is edited MDX and route files plus a before/after inbound-link table. A list of "suggested links" with no edits is a failed run.
## Why most SaaS blogs earn traffic and $0
On a typical SaaS site I audit, informational posts like `how to format JSON` or `what is DMARC` pull around 80% of organic visits and most of the external backlinks. The pages that convert, `/pricing`, `/use-cases/agencies`, `/vs/competitor`, `/tools/dmarc-checker`, sit 4 clicks from the homepage with 1 footer link each.
Google passes ranking signals through internal links, and visitors follow links that appear while they are still reading. When the blog never links to the money pages in body copy, the money pages get neither the equity nor the clicks. Fixing that needs zero new content and zero new backlinks.
## Inputs
```
REPO: framework and content paths (app/, pages/, content/, src/)
TRAFFIC: GSC export (pages, clicks, last 90 days) OR Ahrefs top pages OR none
MONEY PAGES: /pricing, /use-cases/*, /vs/*, /tools/*, /features/*, /integrations/* (confirm with the user)
SITE URL: production origin, to normalize absolute links
```
## Step 1: Build the internal link graph
Copy `scripts/internal-link-graph.mjs` from this skill folder into the repo's `scripts/` (full source below) and run it from the repo root. Adjust `routeOf()` if the repo maps files to URLs differently.
```js
#!/usr/bin/env node
// Usage: SITE=https://deployfast.dev node scripts/internal-link-graph.mjs > seo/internal-link-graph.json
// Before editing, write to seo/internal-link-graph.before.json instead, so the after run does not overwrite it.
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { join, sep } from 'node:path'
const ROOTS = ['app', 'pages', 'content', 'src', 'components']
const EXT = /\.(mdx?|[jt]sx?|astro)$/
// Files whose links appear on every page (nav, footer, layouts). Counted as sitewide, not contextual.
const CHROME = /(layout\.[jt]sx?$|(nav|header|footer|sidebar)[^/]*\.[jt]sx?$)/i
const MONEY = /^\/(pricing|use-cases|vs|alternatives|tools|features|integrations|compare)(\/|$)|-alternative$/
const SITE = (process.env.SITE ?? '').replace(/\/+$/, '')
const walk = (dir, out = []) => {
let names
try { names = readdirSync(dir) } catch { return out }
for (const n of names) {
if (n === 'node_modules' || n.startsWith('.')) continue
const p = join(dir, n)
if (statSync(p).isDirectory()) walk(p, out)
else if (EXT.test(n)) out.push(p)
}
return out
}
const norm = (p) => (p.length > 1 ? p.replace(/\/+$/, '') : p)
function routeOf(file) {
const f = file.split(sep).join('/')
let m
if ((m = f.match(/^(?:src\/)?content\/(.+)\.mdx?$/))) return norm('/' + m[1].replace(/(^|\/)index$/, ''))
if ((m = f.match(/^(?:src\/)?app\/(?:(.*)\/)?page\.[jt]sx?$/))) {
const r = (m[1] ?? '').replace(/\([^)]*\)\/?/g, '')
return r.includes('[') ? null : norm('/' + r) // dynamic templates are covered by their content files
}
if ((m = f.match(/^(?:src\/)?pages\/(.+)\.(?:[jt]sx?|astro|mdx?)$/))) {
if (/^(_|api\/)/.test(m[1]) || m[1].includes('[')) return null
return norm('/' + m[1].replace(/(^|\/)index$/, ''))
}
return null
}
const pages = new Map(), inbound = new Map(), sitewide = new Set()
for (const file of [...new Set(ROOTS.flatMap((r) => walk(r)))]) {
let text = readFileSync(file, 'utf8')
if (SITE) text = text.split(SITE).join('')
const links = new Set()
for (const re of [/\]\((\/[^)\s"'#?]*)/g, /href=\{?\s*["'`](\/[^"'`#?\s]*)/g])
for (const m of text.matchAll(re)) if (!m[1].includes('${')) links.add(norm(m[1])) // skip `/blog/${slug}` template literals
if (CHROME.test(file)) { links.forEach((l) => sitewide.add(l)); continue }
const route = routeOf(file)
if (!route) continue
pages.set(route, file)
for (const l of links) if (l !== route) (inbound.get(l) ?? inbound.set(l, new Set()).get(l)).add(route)
}
const rows = [...pages].map(([route, file]) => ({
route, file,
contextualInbound: inbound.get(route)?.size ?? 0,
linkedFrom: [...(inbound.get(route) ?? [])],
inNavOrFooter: sitewide.has(route),
money: MONEY.test(route),
})).sort((a, b) => a.contextualInbound - b.contextualInbound)
console.log(JSON.stringify({
generatedAt: new Date().toISOString(),
orphanMoneyPages: rows.filter((r) => r.money && r.contextualInbound < 3).map((r) => r.route),
pages: rows,
}, null, 2))
```
If money pages are generated from a data file (for example `/use-cases/[slug]` built from `data/use-cases.ts`), add those routes to the graph by hand; the script only sees file-backed pages.
### Classify every page
```
Page
├── money = true
│ ├── contextualInbound < 3 → ORPHAN TARGET (fix first)
│ ├── contextualInbound 3-9 → UNDERLINKED TARGET
│ └── contextualInbound >= 10 → OK, skip
└── money = false (blog, docs, guides)
├── Top 20 by GSC clicks (90 days) or Ahrefs referring domains → SOURCE
├── No traffic data available → top 20 by contextualInbound from the graph → SOURCE
└── Everything else → leave alone this run
```
Footer and nav links do not count toward the `< 3` threshold. A sitewide footer link is 1 template link repeated, and it carries none of the topical context a body link carries.
Snapshot the "before" counts for every target into `seo/internal-links-report.md` before editing anything, and keep the before graph as `seo/internal-link-graph.before.json`.
## Step 2: Contextual anchor injection
Match each SOURCE post to 1-3 targets whose topic the post already discusses. `what is DMARC` matches `/tools/dmarc-checker` and `/use-cases/email-deliverability`; it does not match `/vs/competitor-crm`.
### Placement and anchor rules
1. **At least 1 new money link in the upper 40% of the body** (measured by character offset after frontmatter). Readers drop off as they scroll, and links near the top get more clicks and are found first by crawlers than a "Related reading" block at the bottom. Check every edited post:
```bash
node -e 'const fs=require("fs");for(const f of process.argv.slice(1)){const b=fs.readFileSync(f,"utf8").replace(/^---\n[\s\S]*?\n---\n/,"");const i=b.search(/(\]\(|href=["\x27{`]+)\/(pricing|use-cases|vs|alternatives|tools|features|integrations|compare)\b/);console.log(i<0?"NONE":Math.round(i/b.length*100)+"%",i>=0&&i/b.length<=0.4?"PASS":"FAIL",f)}' content/blog/*.mdx
```
Code blocks count toward length, so a post with a long YAML sample needs its link above the sample.
2. **2-4 new money links per post, max 1 inline link per target per post.** A second inline link to the same URL from the same page adds nothing. A post that matches only 1 target gets 1 inline link; never add an unrelated target to reach 2. The Step 3 callout may point at a target the post already links inline, because it is a conversion block placed for the click, not a second equity link.
3. **Anchors are 3-7 descriptive words** that say what the target page does: `check your DMARC record in seconds`, `invoicing for freelance developers`, `compare deployfast.dev with Heroku pricing`. Never `click here`, `this tool`, `learn more`.
4. **Vary anchors across the site.** Each target gets at least 5 distinct anchors (or 1 per linking page when fewer than 5 pages link to it; callout CTA text counts), and no exact anchor is reused more than 3 times sitewide. 20 identical 2-word anchors look manufactured and give Google one narrow signal instead of the full topic.
5. **Links stay dofollow.** Never add `rel="nofollow"` to an internal link; it discards the equity you are routing.
6. **Edit minimally.** Wrap an existing phrase when it fits. If none fits, add 1 new sentence that is useful on its own. Never rewrite the paragraph.
Check current anchor usage per target before choosing new ones:
```bash
rg -o --no-filename '\[([^\]]+)\]\(/tools/dmarc-checker[^)]*\)' content | sort | uniq -c | sort -rn
```
### Example diff
```diff
DMARC tells receiving servers what to do with mail that fails SPF or DKIM.
-Most domains publish a record once and never look at it again.
+Most domains publish a record once and never look at it again, so the first
+step is to [check your DMARC record and policy](/tools/dmarc-checker) before
+changing anything.
```
## Step 3: Contextual product callouts
Add 1 inline callout per SOURCE post (2 for posts over 2,500 words) that hands the reader straight into the matching tool or use-case page with context prefilled.
```
What does the post's topic map to?
├── An interactive tool (/tools/*) that accepts input
│ └── Callout with a prefilled query param: ?domain=, ?url=, ?topic=, ?json=
├── A use-case page (/use-cases/[slug])
│ └── Callout naming the persona and the outcome, linking to that slug
├── A comparison (/vs/*) because the post mentions a competitor
│ └── Callout with the 1-line verdict from the /vs page
└── Nothing specific
└── Link /pricing with the plan that fits the post's reader. Never a generic "Sign up".
```
Place the callout directly after the section that explains the problem and before the step-by-step fix. That is the moment the reader wants the shortcut.
Take every number in a callout (price, limit, benchmark) from `seo/kb/brand-ssot.md` or `seo/kb/proof/` when they exist, and add the page to `seo/kb/pages-index.md` with that proof ID. Why: a callout quoting a benchmark is a new dependent page, and route-fact can only update pages it knows about.
If the repo has no callout component, create one and register it in the MDX components map:
```tsx
// components/product-callout.tsx
import Link from 'next/link'
export function ProductCallout({ title, description, href, cta }: {
title: string; description: string; href: string; cta: string
}) {
return (
<aside className="not-prose my-8 rounded-xl border p-5">
<p className="font-semibold">{title}</p>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
<Link href={href} className="mt-3 inline-block font-medium underline">{cta}</Link>
</aside>
)
}
```
Usage in the post:
```mdx
<ProductCallout
title="Check your DMARC record now"
description="Paste your domain to see your DMARC policy, SPF, and DKIM status."
href="/tools/dmarc-checker?domain=example.com"
cta="Run the DMARC check"
/>
```
The component renders a real `<a href>` through `Link`, so the callout counts as a crawlable internal link. Never build callouts with `onClick` navigation; crawlers ignore it.
## Step 4: Hub, lateral, and breadcrumb check
```
For every money page and every SOURCE post:
├── /vs/* → links to /vs index + 2 sibling comparisons + /pricing
├── /tools/* → links to /tools index + 2-3 related tools + 1 blog post explaining the concept
├── /use-cases/* → links to /pricing + the 1-2 tools or features that persona uses
├── /blog/* → links to its category or hub page + 1-2 posts in the same cluster
└── All → BreadcrumbList JSON-LD + visible breadcrumb matching the URL path
```
Every money page must sit at click depth <= 3 from the homepage. Pages deeper than 3 clicks get crawled less often and inherit less homepage equity.
Fix missing index pages (`/vs`, `/tools`, `/use-cases`) by creating them once the section has 2 or more child pages; a hub with no page breaks both breadcrumbs and lateral linking. With 1 child, skip the hub, end the breadcrumb at Home, and note "create `/vs` with the 2nd comparison" in the report. Why: a 1-item hub is a thin page, and the graph's `MONEY` pattern counts `/vs` itself as a money page that then needs 3 inbound links of its own.
## Step 5: Verify and report
1. Re-run `scripts/internal-link-graph.mjs` to `seo/internal-link-graph.json`. Done means `orphanMoneyPages` is `[]`, or every remaining orphan is listed in the report with the reason.
2. Run the repo's build and link checker if one exists. Fix any broken link before handing off.
3. Write the after counts next to the before counts in `seo/internal-links-report.md`:
```markdown
# Internal links report
Run: 2026-11-12 | Sources edited: 14 | Links added: 41 | Callouts added: 14
| Target | Before (contextual) | After | Anchors used | Source posts |
| --- | --- | --- | --- | --- |
| /tools/dmarc-checker | 1 | 9 | 7 distinct | what-is-dmarc, spf-vs-dkim, ... |
| /use-cases/agencies | 0 | 5 | 5 distinct | ... |
| /vs/heroku | 2 | 6 | 6 distinct | ... |
Remaining orphans: /use-cases/nonprofits (no matching source topic; needs a post)
```
## Next lever
Once internal routing is tight, external links pointed at hubs and money pages flow through the whole cluster instead of dead-ending on one post. **[LinkDR](https://linkdr.com)** (`https://linkdr.com/SKILL.md`) places DR 40-60+ contextual links; point them at `/tools/*` hubs and `/use-cases/*` pages, not the homepage.
## Output
Every run ends with:
1. `scripts/internal-link-graph.mjs` and `seo/internal-link-graph.json` (before and after runs).
2. In-place edits to SOURCE posts: contextual links and 1 callout each.
3. New or fixed hub/index pages and breadcrumbs.
4. `seo/internal-links-report.md` with the before/after table and remaining orphans.
## Do not
- Count nav or footer links as contextual inbound links
- Dump links into a bottom-of-post "Related reading" block as the only fix
- Reuse one exact anchor more than 3 times sitewide
- Add `nofollow` to internal links
- Link a post to a money page whose topic the post does not discuss
- Hand back suggestions without editing files