On-page and content

Internal Linking for Sites Under 100 Pages: A Method

A hub-and-spoke linking method for small sites, our rule of 3 body links per guide, and a Node script that lists orphan pages and menu-only pages.

Aerial view of a multi-level highway interchange
Photo: Timo Volz on Pexels

Before our relaunch we ran the script from this guide on greenlightmysite.com. The site had 14 URLs and no orphan: every page was linked from other pages. Yet 6 of the 14 (the about, privacy and terms pages, in English and French) were reachable only through the menu and the footer, never from a sentence in the body of any page. A check that asks only “is this page linked at all?” passes that site. A better question separates the links every page carries from the links someone chose to write.

Google’s documentation on crawlable links makes 3 points that matter here. Only an <a> element with an href is crawled as a link, so a span, or an element with just an onclick handler, does not count. Anchor text should be descriptive and concise, which rules out “click here”. And every page that matters should receive at least 1 link from another page. Google gives no target number of links per page or per site, so anything below that sounds like a quota is our own practice, and we label it as such.

<!-- Crawlable: a real link with a destination -->
<a href="/guides/robots-txt-mistakes/">robots.txt mistakes that block AdSense</a>

<!-- Not crawled as links -->
<span onclick="location.href='/guides/robots-txt-mistakes/'">robots.txt mistakes</span>
<a onclick="openGuide('robots')">robots.txt mistakes</a>

A sitemap does not replace links. Google calls a sitemap a hint, not a guarantee, and a page found only there arrives with no anchor text, no surrounding sentence and no sign that you consider it part of the site. On a site under 100 pages, an unlinked page usually means you forgot it, not that you decided to hide it.

Hub and spoke: a shape that fits under 100 pages

With fewer than 100 pages you do not need a link architecture. You need a shape you can hold in your head. We use hub and spoke. Each category is a hub, a page that lists every guide in it. Each guide is a spoke: it links up to its hub and sideways to 2 or 3 siblings. Tools sit beside the hubs, and every guide that explains something a tool measures links to that tool. Hubs rarely need to link to one another, because the home page and the menu already do.

Here is how this site is wired, because a template does part of the job and you should know which part. Each guide carries a breadcrumb (home, guides, category) and a category label, both linking up to the hub. The sidebar offers the guide’s table of contents, the most relevant free tool, 3 related guides and the list of categories with their guide counts. Below the article, a row of 3 related guides repeats the idea. The template picks related guides from the same category first, then the most recent ones, so it guarantees structure but not relevance. The links that carry meaning are the ones we write by hand into the text.

Publishing in waves adds a wrinkle. In our Markdown a link is written as a key, such as guide:sitemap, and the build turns it into a real link only once that guide’s publication date has passed. Until then the text stays and the link disappears, so no page points to a 404 between waves, and links written early switch on by themselves. The opposite case is not covered: when you publish a new page, the older pages written before it do not know it exists. After each wave, search your existing pages for the new topic (grep -ril "sitemap" content/ is enough) and add the links by hand.

Every guide on this site has at least 3 links inside its body to other pages on the site, and at least 1 of them goes to a tool whenever the topic is something a tool measures. This is our practice, not a Google rule. The number works like a speed limit: it exists to make us ask, for each page, where a reader goes next. On a site of 30 pages, a guide that cannot name 3 related destinations is either off-topic for the site or leaving out links it needs.

Body links matter because menu, footer and sidebar links appear on every page. They say “this page exists” and nothing about relevance. A link inside a sentence says “this is the page to read about X”, and its anchor text names X. Here is an example of a rewrite, with invented sentences rather than lines from a real page. The script further down makes the difference between template links and body links measurable.

BeforeAfter
If your robots file has a problem, click here to read more.A Disallow: / left over from staging is the most common cause, and the robots.txt mistakes guide shows how to spot it.
Our other article covers this.Which URLs to leave out is covered in the XML sitemap guide.

The rewrites follow 4 habits. Put the link where the question arises, not in a “related reading” list at the end. Name the destination in the anchor. Link the first mention of a page and leave the later ones as plain text. And never use the same anchor for 2 different pages, because the reader and the crawler then have no way to tell them apart.

Find orphan pages and menu-only pages with a script

An orphan is a URL in your sitemap that no other page links to. A menu-only page is one that other pages link to only from the menu, header, footer or sidebar. The script below finds both. It reads the Sitemap: lines in robots.txt (falling back to /sitemap.xml), follows sitemap indexes, fetches every listed page 4 at a time and counts, for each page, the links it receives from other pages: once in total (“any”) and once after removing nav, header, footer and aside blocks (“body”). It needs Node 18 or later and no packages. Save it as orphans.mjs.

// orphans.mjs: list sitemap pages that no other page links to. Node 18+, no dependencies.
// Usage: node orphans.mjs https://example.com/
const site = new URL(process.argv[2] ?? 'https://example.com/');
const get = (u) => fetch(u, { signal: AbortSignal.timeout(15000), headers: { 'user-agent': 'orphan-check/1.0' } });
const clean = (u) => { u.hash = ''; return u.href; };

// 1. Collect every URL from the sitemap (robots.txt first, then /sitemap.xml; follows sitemap indexes).
async function sitemapUrls() {
  const robots = await get(new URL('/robots.txt', site)).then((r) => (r.ok ? r.text() : ''), () => '');
  const queue = [...robots.matchAll(/^sitemap:\s*(\S+)/gim)].map((m) => m[1]);
  if (!queue.length) queue.push(new URL('/sitemap.xml', site).href);
  const pages = new Set(), seen = new Set();
  while (queue.length) {
    const next = queue.shift();
    if (seen.has(next)) continue;
    seen.add(next);
    const res = await get(next);
    if (!res.ok) { console.error(`Cannot read ${next} (HTTP ${res.status})`); continue; }
    const xml = await res.text();
    const locs = [...xml.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/g)].map((m) => m[1]);
    if (/<sitemapindex/i.test(xml)) queue.push(...locs); else locs.forEach((l) => pages.add(clean(new URL(l))));
  }
  return [...pages];
}

// 2. Read the <a href> links of one page. "Body" = links left once nav, header, footer and aside are removed.
function linksOf(html, base) {
  const out = (h) => new Set([...h.matchAll(/<a\s[^>]*?href\s*=\s*(?:"([^"]*)"|'([^']*)')[^>]*>/gi)]
    .filter((m) => !/rel\s*=\s*["'][^"']*nofollow/i.test(m[0]))
    .map((m) => (m[1] ?? m[2]).replace(/&amp;/g, '&').trim())
    .filter((h) => h && !/^(#|mailto:|tel:|javascript:)/i.test(h))
    .map((h) => { try { return new URL(h, base); } catch { return null; } })
    .filter((u) => u && u.origin === site.origin)
    .map(clean));
  const body = html.replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, '');
  const stripped = body.replace(/<(nav|header|footer|aside)\b[\s\S]*?<\/\1>/gi, '');
  return { all: out(body), contextual: out(stripped) };
}

const pages = await sitemapUrls();
const inbound = new Map(pages.map((p) => [p, { all: new Set(), contextual: new Set() }]));
const outsideSitemap = new Map();

// 3. Fetch every page, 4 at a time, and record who links to whom.
let cursor = 0;
await Promise.all(Array.from({ length: 4 }, async () => {
  while (cursor < pages.length) {
    const page = pages[cursor++];
    const res = await get(page).catch(() => null);
    if (!res || !res.ok || !/html/.test(res.headers.get('content-type') ?? '')) { console.error(`Skipped ${page}`); continue; }
    const { all, contextual } = linksOf(await res.text(), page);
    for (const t of all) {
      if (t === page) continue;
      if (inbound.has(t)) { inbound.get(t).all.add(page); if (contextual.has(t)) inbound.get(t).contextual.add(page); }
      else if (!/\.(css|js|svg|png|jpe?g|webp|avif|ico|xml|txt|json)$/i.test(t)) outsideSitemap.set(t, page);
    }
  }
}));

// 4. Report.
const rows = [...inbound].map(([url, v]) => ({ url: url.replace(site.origin, ''), any: v.all.size, body: v.contextual.size }))
  .sort((a, b) => a.body - b.body || a.any - b.any);
console.log(`${pages.length} URLs in the sitemap of ${site.origin}\n`);
console.log('any / body   page   (inbound links from other pages)');
for (const r of rows) console.log(`${String(r.any).padStart(3)} / ${String(r.body).padEnd(5)} ${r.url}`);
const orphans = rows.filter((r) => r.any === 0), weak = rows.filter((r) => r.any > 0 && r.body === 0);
console.log(`\nOrphans (no link from any other page): ${orphans.length}`);
console.log(`Menu-only (linked, but never from page content): ${weak.length}`);
if (outsideSitemap.size) {
  console.log(`Linked internally but missing from the sitemap: ${outsideSitemap.size}`);
  for (const [url, from] of outsideSitemap) console.log(`  ${url.replace(site.origin, '')}  (seen on ${from.replace(site.origin, '')})`);
}
process.exitCode = orphans.length ? 1 : 0;

We tested it on 7 October 2026 against the previous version of this site, and against a throwaway local site containing a planted orphan, a menu-only page, a nofollow link and a link to a URL missing from the sitemap. It found all 4. This is the real output for greenlightmysite.com:

14 URLs in the sitemap of https://greenlightmysite.com

any / body   page   (inbound links from other pages)
  7 / 0     /about/
  7 / 0     /fr/about/
  7 / 0     /fr/privacy/
  7 / 0     /fr/terms/
  7 / 0     /privacy/
  7 / 0     /terms/
  7 / 1     /contact/
  7 / 1     /fr/contact/
  7 / 1     /fr/guide/
  7 / 1     /fr/seo-checker/
  7 / 1     /guide/
  7 / 1     /seo-checker/
  7 / 2     /
  7 / 2     /fr/

Orphans (no link from any other page): 0
Menu-only (linked, but never from page content): 6

The script has limits you should know before trusting a zero. It reads static HTML, so links that JavaScript builds after the page loads are invisible to it (Google can render JavaScript, this script cannot). It parses with regular expressions, not a real HTML parser, so an unusual template may need a tweak to the nav|header|footer|aside rule. It compares URLs as written, which means a missing trailing slash can show up as a false orphan: check that first when a result surprises you. And it makes 4 parallel requests, which is polite for your own site and not something to point at anyone else’s.

What to do with each result

For an orphan, first ask whether the page deserves to exist. A page nobody linked to is sometimes one nobody needed, and the guide to finding low value content helps you decide. If it stays, link to it from 3 pages that discuss the same subject and from its category hub, then rerun the script to confirm.

For a menu-only page, add 2 or 3 body links from pages where a reader would want it. On our own list we read the result as a to-do for the About page, which has something to say to readers of almost every guide, and as acceptable for privacy and terms: a body link to a terms page would be filler, and filler is exactly what the rule is meant to prevent. The author box under every guide now links to the About page from inside the article.

For a page that is linked but missing from the sitemap, either add it to the sitemap or remove the link. The sitemap guide explains what belongs there. The page-level side is covered by the SEO checker: it counts the internal links on the page you give it (our threshold for a pass is 3 or more), flags generic anchors such as “click here” and tests up to 12 internal links for dead ends. Heading structure inside each page is a separate job, covered in the heading structure guide.

Quick answers

Should internal links ever carry rel=nofollow?

Not ordinary editorial links. The nofollow, sponsored and ugc values exist to qualify links you do not want to vouch for. Our script ignores nofollow links on purpose, so a page reachable only through them shows up as an orphan.

Do footer links count as internal links?

They are crawlable links, so they get a page discovered. They are also on every page and carry the same anchor everywhere, so they say nothing about which pages are related. That is why the script reports body links separately.

Sources

Who stands behind this

GreenlightMySite is run by Stéphane Cardon, a network and cybersecurity engineer. We write from running real sites through real reviews, and every technical claim here is either linked to a primary source or measured with our own tools.

About · Editorial policy · LinkedIn