Design a strategic internal linking structure
Optimized navigation, strategic linking and automated links.

Internal linking is the part of a programmatic site people generate last and should design first. Your template decides, for every page at once, which URLs Google can reach, which ones look important and where a visitor goes next. A bad rule gets copied to every page, and so does a good one.
Throughout this chapter, say you run a job board with 40 job categories and 500 French cities. That gives 20,000 possible pages like “Nursing jobs in Lyon”, and no human will ever place those links by hand.
What links actually do on a generated site
The first job is discovery. A sitemap tells Google a URL exists, but a page with no incoming link is an orphan: it has no context, no path for a crawler and very little reason to be prioritized. On a large site, orphans are often the pages that sit at “Discovered - currently not indexed” for months.
The second job is distributing PageRank. Your homepage, your guides and your best hub pages collect most of the backlinks. Internal links are how that authority reaches the long tail. If a “Best nursing employers” guide earns links from the press, it should link down to the city pages it mentions.
The third job is moving people. A visitor on “Nursing jobs in Lyon” with too few openings wants Villeurbanne next door, or care assistant roles in Lyon. A SaaS with use case pages has the same logic pointing elsewhere: trial, customer proof, the detailed tutorial. Links are where your page stops being a dead end, and they should lead somewhere a visitor would actually choose to go.
Build the hierarchy before the links
I always draw three levels before writing a single link rule. Each one has a different job.
| Level | Job board example | Volume | What it does |
|---|---|---|---|
| Pillar | Nursing jobs in France | 40 | Targets the broad query, collects links from everywhere below |
| Secondary | Nursing jobs in Brittany | 40 × 13 regions | Groups cities, lists all of them |
| Detail | Nursing jobs in Rennes | up to 20,000 | Targets the precise query, carries the real listings |
With this shape, every city page sits three clicks from the homepage: home, pillar, region, city. That only holds if each region page lists its cities in full, in plain links. A “top 10 cities” widget leaves the other 490 stranded.
Links then run in three directions. Up, through the breadcrumb. Down, through the lists on pillars and regions. Sideways, between pages that share a job or a place, and this last direction is where programmatic sites have an edge because the data already knows what is related.
The link blocks each template needs
| Block | Where | Rule from the data | Sample anchor |
|---|---|---|---|
| Breadcrumb | Top of page | Parent category, parent region | Nursing jobs in Brittany |
| Nearby cities | After the listings | Same job, 5 closest cities with open roles | Nursing jobs in Vannes |
| Related jobs | After the listings | Same city, 5 closest job categories | Care assistant jobs in Rennes |
| Contextual link | Inside the intro | One link to the most relevant guide | what a nurse earns in Brittany |
| Conversion | Sidebar or end of page | Same link everywhere, wording varies by job | post a nursing job |
The contextual link matters more than people expect. A link sitting in a sentence carries more meaning than one in a list, so write at least one into the template’s editorial text, with the target picked from the data.
Generate links from the data, then store them
Treat links as data. I compute them into a table rather than improvising them in the template, because a table can be audited, diffed and rolled back. Here is the sideways block in PostgreSQL, assuming a pages table and a city_neighbors table with distances:
INSERT INTO page_links (source_id, target_id, block)
SELECT p.id, t.id, 'nearby_cities'
FROM pages p
JOIN LATERAL (
SELECT target.id
FROM city_neighbors cn
JOIN pages target
ON target.city_id = cn.neighbor_id
AND target.job_id = p.job_id
WHERE cn.city_id = p.city_id
AND target.published
AND target.open_roles > 0
ORDER BY cn.distance_km
LIMIT 5
) t ON true
WHERE p.published;
The two filters inside the subquery do the important work. Never link to an unpublished page, and never link to an empty one. A page with zero openings should not be published at all: Google’s scaled content abuse policy targets mass pages with little value, and your link graph is a very direct statement of which pages you consider worth visiting.
If you want to see this as a production variable, internal linking was one of the 90+ variables on the Deux.io project, alongside titles, schemas and featured articles. The structure of the table depends on your model, which the database chapter covers.
Vary anchors, but keep them stable
Thousands of identical anchors pointing at one page look mechanical and describe less than they could. Write a few patterns and assign one per source page.
| Pattern | Output |
|---|---|
{job} jobs in {city} |
Nursing jobs in Vannes |
{job} roles around {city} |
Nursing roles around Vannes |
Work in {job_lower} in {city} |
Work in nursing in Vannes |
Pick the pattern with something deterministic such as source_id % 3. If you use a random draw, every build rewrites thousands of anchors for no reason, and you lose the ability to tell what changed when rankings move.
Output HTML a crawler can follow
Google follows <a> elements with an href. Its link documentation is short and worth reading once. Render these blocks server side, in the initial HTML.
<nav aria-label="Nearby cities">
<h2>Nursing jobs near Rennes</h2>
<ul>
<li><a href="/jobs/nursing/vannes">Nursing jobs in Vannes</a></li>
<li><a href="/jobs/nursing/saint-malo">Nursing roles around Saint-Malo</a></li>
</ul>
</nav>
A <span onclick>, a button that loads more results, or a block injected after a user interaction will not do this job. Leave nofollow out of your internal links too, since it asks Google not to rely on your own link and gives you no useful control over PageRank. The crawlers feeding AI answers need the same plain HTML paths, and many of them execute less JavaScript than Googlebot does.
The breadcrumb deserves its visible links plus its BreadcrumbList markup, which the structured data chapter walks through.
Audit the graph on a schedule
Because links live in a table, finding orphans is one query:
SELECT p.slug
FROM pages p
LEFT JOIN page_links l ON l.target_id = p.id
WHERE p.published
AND l.target_id IS NULL;
Then compare with reality. Crawl the site with Screaming Frog from the homepage and diff the crawled URLs against your published list: any gap means the HTML does not match the table. In Search Console, the Links report shows which pages get the most internal links, and that list should look like your hierarchy. The Page indexing report shows whether the detail pages are stuck at the discovery stage. I cover running these checks automatically in the SEO audits chapter.
Your next step: draw the three levels for your own site on one page, write the rule behind each link block in one sentence, and check that every rule can be answered by a column you already have. Any block that needs a column you lack goes on the data model to-do list before the template gets built.