seo-automation
Blog

Automate Internal Linking on a Markdown Blog

November 8, 2026

To automate internal linking on a Markdown blog, run a script on your content folder. The script maps which posts link to which and finds the posts that nothing links to. It then matches your real Search Console queries against the text of other posts and suggests links. A person approves each one before merge. You don't need a WordPress plugin. The content is already plain files in Git, so a Node script, a scheduled GitHub Action and a pull request cover most of the work.

This guide is for teams whose blog is Markdown or MDX in a repo, rendered by Next.js, Astro, Hugo or a headless CMS. It walks through the pipeline step by step with short scripts. It also covers the checks that keep links visible to Google, the parts a human still has to decide, and a way to measure the result.

Start for $1 and get your SEO plan in a few minutes.

What automated internal linking means (four approaches)

Automated internal linking means software decides where links go, or suggests where they could go, so you don't have to open every post and hunt for places to add them. In practice there are four levels. Each one gives the machine more control than the one before.

Approach How it works Where it fits Main risk
Keyword rules A list of "phrase → target page" rules applied to every post Small, stable blogs with clear topics Same exact anchor everywhere, links in the wrong context
Suggestions (scripted or AI) A script or model suggests links, a person accepts or rejects them Most SaaS and agency blogs Review backlog if nobody owns it
Fully automatic insertion Links are written into the content with no review Very large sites with strict rules Off topic links, anchor over optimization
Links generated by code at build time Templates render "related posts", breadcrumbs and pillar links from front matter Every framework blog Links that only exist in the browser, never in the HTML

On a Markdown blog you usually combine two of these. Links generated by code come from the layout: breadcrumbs, a pillar link, related posts from shared tags. Links in the body come from reviewed suggestions. The template links make sure every post gets linked, and the body links add descriptive anchors where they make sense.

Why WordPress plugins don't solve this for a Git blog

Most tools that rank for this topic are plugins or SaaS products that write into a CMS database. If your posts live in content/blog/*.md, there is no database to write into. The repo is the source of truth, and every change should go through a commit someone can review and revert. That works in your favor. You can diff every link change, roll back a bad rule in one commit, and run checks in CI before anything ships.

You build, run and review the linking pipeline in this guide yourself. If you want the rest of the content work off your plate, SEO Automation handles it. Five AI agents find the real searches, write sourced articles, check every fact and every link, and publish to GitHub, WordPress and other platforms. They then send the sitemap to Search Console and notify IndexNow. If you want to build the linking side yourself, read on.

How to automate internal linking in Next.js and Astro, step by step

The pipeline below works the same with Next.js, Astro or Hugo, because it runs on the Markdown files, not on the framework.

  1. Build the link graph. List every post and every internal link it contains, then count the inbound links for each post.
  2. Find orphans and weak pages. Start with posts that get no inbound links from other posts. Then look at posts that sit many clicks away from the blog index.
  3. Pull Search Console queries per page. The queries a post already gets impressions on give you anchors that describe it in your audience's own words.
  4. Write link rules. One rule per target page: the target URL plus 2 to 5 natural anchor phrases taken from step 3.
  5. Generate suggestions. Scan the other posts for those phrases, skip code blocks, headings and existing links, and output a list.
  6. Review in a pull request. A person accepts, edits or rejects each suggestion. Accepted links are committed.
  7. Check the rendered HTML in CI. Fail the build if a link points to a missing slug, a redirect or a page you don't want indexed.
  8. Rerun on a schedule and on every new post. New posts need inbound links the day they ship, not three months later.

Step 1: extract the link graph from your Markdown

This script reads content/blog, extracts links to /blog/..., and writes inbound counts and orphans to a JSON file.

// scripts/link-graph.mjs
import fs from 'node:fs';
import path from 'node:path';

const dir = 'content/blog';
const files = fs.readdirSync(dir).filter(f => /\.mdx?$/.test(f));
const slugOf = f => f.replace(/\.mdx?$/, '');
const inbound = Object.fromEntries(files.map(f => [slugOf(f), 0]));
const outbound = {};

for (const f of files) {
  const body = fs.readFileSync(path.join(dir, f), 'utf8');
  const links = [...body.matchAll(/\]\((\/blog\/[^)#?\s]+)/g)]
    .map(m => m[1].replace(/\/$/, '').split('/').pop());
  outbound[slugOf(f)] = [...new Set(links)];
  for (const s of new Set(links)) if (s in inbound && s !== slugOf(f)) inbound[s]++;
}

const orphans = Object.keys(inbound).filter(s => inbound[s] === 0);
fs.writeFileSync('link-graph.json', JSON.stringify({ inbound, outbound, orphans }, null, 2));
console.log(`${files.length} posts, ${orphans.length} with no inbound link from another post`);

This script only sees links inside post bodies. Your layout may already link to posts from the blog index, tag pages or a sidebar. To see everything, crawl the built site once (any crawler that exports inlinks per URL works) and merge the result with the JSON. Google's guidance is direct: every page you care about should have a link from at least one other page on your site (Google Search Central).

Step 3: pull queries per page from the Search Console API

The Search Analytics query method returns clicks, impressions and position grouped by the dimensions you request. Ask for query and page together:

POST https://www.googleapis.com/webmasters/v3/sites/sc-domain%3Aexample.com/searchAnalytics/query
{
  "startDate": "2026-08-01",
  "endDate": "2026-10-31",
  "dimensions": ["page", "query"],
  "dimensionFilterGroups": [{
    "filters": [{ "dimension": "page", "operator": "contains", "expression": "/blog/" }]
  }],
  "rowLimit": 25000
}

For each page, keep the queries with real impressions and drop the branded ones. What's left is your list of possible anchors. Say /blog/nextjs-dynamic-sitemap gets impressions on "dynamic sitemap next js" and "generate sitemap app router". Then "dynamic sitemap in Next.js" and "generate a sitemap with the App Router" make natural anchors, because they describe the page the way searchers do.

This data also shows which pages to push. A post at position 8 to 20 with growing impressions is a better target than a post nobody searches for. Give it inbound links from closely related posts first.

Step 4 and 5: rules file and suggestion script

Keep the rules in the repo as data, so that changing a rule is a reviewed commit:

[
  { "target": "/blog/nextjs-dynamic-sitemap",
    "anchors": ["dynamic sitemap in Next.js", "generate a sitemap with the App Router"] },
  { "target": "/blog/search-console-api-guide",
    "anchors": ["Search Console API", "pull queries per page"] }
]

The suggestion script suggests at most one link per target per post. It skips the post itself, posts that already link to the target, code blocks and headings:

// scripts/suggest-links.mjs
import fs from 'node:fs';
const rules = JSON.parse(fs.readFileSync('link-rules.json', 'utf8'));
const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const out = [];

for (const file of fs.readdirSync('content/blog').filter(f => /\.mdx?$/.test(f))) {
  const self = '/blog/' + file.replace(/\.mdx?$/, '');
  const body = fs.readFileSync('content/blog/' + file, 'utf8');
  const prose = body
    .replace(/```[\s\S]*?```/g, '')      // code blocks
    .replace(/^#.*$/gm, '')               // headings
    .replace(/\[[^\]]*\]\([^)]*\)/g, ''); // existing links
  for (const { target, anchors } of rules) {
    if (target === self || body.includes(`](${target}`)) continue;
    const hit = anchors.find(a => new RegExp(`\\b${esc(a)}\\b`, 'i').test(prose));
    if (hit) out.push({ file, anchor: hit, target });
  }
}
fs.writeFileSync('link-suggestions.json', JSON.stringify(out, null, 2));

Phrase matching is the cheapest way to judge relevance. If you want suggestions based on meaning, compute embeddings for each post and suggest links between the posts that are closest in topic. Keep the same output format so the review step stays the same. An LLM can also reword a sentence so the anchor reads naturally. Treat that rewording as a suggestion too, never as an automatic edit.

Step 6 and 8: run it on a schedule with GitHub Actions

name: internal-links
on:
  schedule:
    - cron: '0 6 * * 1'   # Mondays 06:00 UTC
  workflow_dispatch:
jobs:
  suggest:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: node scripts/link-graph.mjs
      - run: node scripts/suggest-links.mjs
      - uses: actions/upload-artifact@v4
        with:
          name: link-report
          path: |
            link-graph.json
            link-suggestions.json

From there, a reviewer applies the accepted suggestions in a branch. If your team trusts the rules, you can add a step that writes them and opens a pull request. Also add a pull_request trigger on content/blog/**. That way every new post gets a report listing its own possible inbound links before it is merged.

On Astro, you can also apply link rules at build time with a remark or rehype plugin registered in the Markdown processor (Astro Markdown docs). This leaves the source files untouched. The downside is that the links no longer show up in the diff, which makes them harder to review. For most teams, editing the files through a reviewed commit is easier to audit.

You run this pipeline yourself: the scripts, the rules file and the review of every link stay with your team. SEO Automation handles the articles themselves. Its five agents find real searches, write sourced articles, check every fact and every link, publish to your GitHub repo, and send the sitemap to Search Console and IndexNow. Start for $1 and get your SEO plan in a few minutes.

Make sure Google can actually see your internal links

If Google can't crawl a link, adding it was wasted work. Google says it can reliably follow links only when they are an HTML <a> element with an href attribute. Links created with JavaScript events may not be parsed (Google Search Central).

What this means for each stack:

  • Next.js. <Link> from next/link renders an <a> element with your href (Next.js docs). Markdown links rendered by MDX or a remark pipeline also produce <a href>. The risk is code like <div onClick={() => router.push('/blog/x')}> for cards or "read next" blocks. Use <Link> instead.
  • Related posts fetched in the browser. If a related posts block loads in a useEffect after the page loads, its links are missing from the server HTML. Compute related posts at build time or in a server component from front matter tags.
  • Astro. Pages are rendered to HTML on the server by default, so Markdown links come out as plain <a href>. Watch for components hydrated with client:only, because their markup doesn't exist in the server HTML.
  • Headless CMS. Rich text fields often store internal links as entry references, not URLs. Check that your renderer turns those references into <a href="/blog/slug"> and doesn't output an empty href when the entry is unpublished.

A quick CI check on a production or preview URL:

curl -s https://example.com/blog/nextjs-dynamic-sitemap \
  | grep -o '<a [^>]*href="/blog/[^"]*"' | sort -u

If a link you expect is missing from that output, Google may not see it either.

What to automate and what a human must review

Automation is good at counting, matching and checking. Judgment about meaning, intent and priority should stay with a person.

Safe to automate

  • Building the link graph and finding orphans after every merge
  • Pulling Search Console queries per page and ranking target pages by opportunity
  • Suggesting anchors from real queries
  • Blocking links to missing slugs, redirects, noindex pages and pages blocked by robots.txt
  • Limiting how many links a single rule can add per post
  • Links generated by code: breadcrumbs, pillar links from front matter, related posts by shared tag

Keep human

  • Choosing the target page when two pages compete for the same query. Linking both with the same anchor tells Google that two pages answer one intent. Pick one, and consider merging the other into it.
  • Accepting each body link. What matters is whether a reader at that sentence would want that page, and a script can't reliably tell.
  • Pages that should never receive links: thank you pages, gated content, staging URLs and, on some landing sites, routes you deliberately keep out of the index.
  • Pillar structure. Choosing the hub page for a topic is a strategic decision. Set it in front matter (for example pillar: /blog/nextjs-seo) and let code enforce it.
  • Anchor wording. Google notes that anchor text tells users and Google what the linked page is about, and that keyword stuffing violates its spam policies (Google Search Central). Read each anchor in context instead of approving it from a list.

Risks and mistakes to check before you ship

Here is how automated linking goes wrong. Each mistake has a check you can add to the pipeline.

Mistake What it looks like Check to add
Exact match anchors everywhere 40 posts link with "nextjs seo checklist" Rotate anchors per rule, cap uses per anchor
Off topic links "Sitemap" in a post about XML parsing links to your SEO sitemap guide Human review, or topic similarity threshold
Links to non indexable pages Links to noindex, canonicalized or redirected URLs CI check against your sitemap and redirect map
Self and circular links A post links to itself, or two posts only link to each other Skip self, flag pairs with no other inbound links
Link stuffing in one paragraph Three links in two sentences Max one new link per paragraph
Links only in the browser Related posts fetched after load curl check on the rendered HTML
Broken links after a slug change Old slug still linked in 15 posts Fail the build on unknown /blog/ targets
Rules nobody owns Rules file not touched in a year, targets outdated Review the rules file every quarter

A simple checklist before merging each new post:

  1. It has at least two inbound links from related posts, added in the same pull request.
  2. It links to its pillar page, if it has one.
  3. Every link target exists, returns 200 and is in the sitemap.
  4. No anchor appears more than once on the page.
  5. The links appear in the server HTML.

How many internal links should an article have?

Google doesn't give a number. Its guidance is about quality: link where it helps the reader, use anchor text that describes the target page, and don't stuff keywords (Google Search Central).

In a pipeline, a limit is a review setting, not an SEO rule. A practical setup is to cap automatic suggestions per post per run (for example 5) and allow one new link per paragraph. That keeps each pull request short enough to read properly. Reviewers can add more links by hand when the content calls for it. A short post with two relevant links beats a long post with ten forced ones.

When does manual internal linking stop scaling?

The work grows faster than the blog. If 200 posts each need 5 internal links, that's 1,000 placement decisions. And each new post adds work to old posts too, because older related posts should link to it. Few teams ever go back to add those links by hand. Once that happens, a scheduled script is worth it: it lists the missing links every week, and a person only has to approve them.

How the approaches compare on cost:

Manual Your own script Linking tool
Setup None A few scripts, CI job Account, site connection
Ongoing cost Editor time per post Maintenance of rules and scripts Subscription, price varies by tool
Fits a Markdown blog in Git Yes Yes, natively Depends on the tool; many write to a CMS
Reviewable diff Yes Yes Depends on the tool

How to measure if automated internal linking works

Measure the pages that received links, not the whole site.

  1. Export a baseline before you merge. For each target page, record clicks, impressions and average position over the previous 28 days, using the same API call as step 3. Save it with the commit hash.
  2. Track inbound link counts. Rerun link-graph.mjs and confirm that orphans went down and target pages gained links. The Links report in Search Console will show the new internal links once Google recrawls.
  3. Compare after Google has recrawled. Check in Search Console's URL Inspection tool that the pages carrying the new links have been crawled again. Then compare against the baseline over a full 28 day window that starts a few days after that crawl.
  4. Use a control group if you can. Link half of your weak pages this month and the other half next month. If both groups move the same way, the change probably came from something else, such as a Google update or seasonality.

To speed up recrawling after a large link change, resubmit your sitemap in Search Console. Update lastmod only on the pages whose content changed.

Running internal link automation for several client sites

Agencies and freelancers should not share one rules file across clients. Keep one pipeline and one config per site:

sites/
  client-a/  link-rules.json  config.json   # gsc property, content path, never-link list
  client-b/  link-rules.json  config.json

Each config.json holds the Search Console property, the content folder or CMS export, the client's own never-link list and the maximum number of links per run. Run the same scripts in a matrix job, one site per job, and send each client's report to the person who approves their content. With a shared rule like "always link 'technical SEO' to our guide", one client's anchors would end up pointing to another client's pages. Separate configs prevent that.

How SEO Automation handles this

SEO Automation publishes researched articles directly to GitHub based sites (Next.js, Astro, Hugo), WordPress, Webflow and other platforms. Its Editor agent checks every fact and every link before publishing. The Publisher then resubmits the sitemap in Search Console and notifies IndexNow. Articles stuck on page 2 get reworked, and titles with a low CTR get rewritten. You still build, run and review the internal linking pipeline in this guide yourself. The plan costs $49 per site per month, as shown on the pricing page, and the first month is $1. You can also run it from Claude with the Claude connector.

Start for $1 and get your SEO plan in a few minutes.

Frequently asked questions

Is automated internal linking safe for SEO?

Yes, as long as the links help readers and use descriptive anchors. Google's guidance is about link quality, not about how the links were added, and it warns against keyword stuffing in anchors (Google Search Central). The problems come from fully automatic insertion with no review: repeated exact anchors and links in the wrong context.

Can you automate internal linking on a headless CMS?

Yes. Export entries through the CMS API, build the link graph from the rich text fields, and generate suggestions the same way as for Markdown files. Write the accepted links back through the CMS API or apply them in your renderer. Then check that the rendered HTML contains real <a href> links, not unresolved entry references.

Are related posts widgets enough?

They help make sure every post gets some links, especially new posts, but they rarely replace links in the body. A widget usually links by tag or date, with the post title as the anchor. A body link sits in a sentence that tells the reader why to go there. Use both, and render the widget on the server so its links are in the HTML.

How fast do internal linking changes show up in Google?

Not right away. Google first has to recrawl the pages that carry the new links, and the timing depends on how often it crawls your site. Resubmitting the sitemap after a big change can get pages recrawled sooner, but it doesn't guarantee a ranking change. Compare against a baseline you saved before the merge, not against last week.