How to check nofollow links
Take an inventory rather than spot-checking one anchor. Run node link-rel.cjs https://host/page, which prints every link on the page with its rel value and whether it points inside the site. Paid and user-generated links should carry a marker, and internal links should not carry one by accident.
Why check this
Run this on any template that renders links from data: a blog post, a comments thread, a partner directory, a sponsored placement. Run it again after a CMS upgrade, because the attribute is usually added by a plugin and lost by a theme.
Two failures sit at opposite ends. A paid placement ships without a marker, which is the case the marker exists for. Or a navigation partial adds rel="nofollow" to internal links, and the site asks search engines not to follow its own routes. The second is harder to notice, because nothing on the page looks different.
The meaning of the attribute changed, and the dates matter. rel="nofollow" was introduced in 2005 as an instruction. On 2019-09-10 Google introduced rel="sponsored" for paid links and rel="ugc" for user-generated ones, and made all three work as hints for ranking. From 2020-03-01 nofollow became a hint for crawling and indexing as well. A hint can be overruled, so a nofollowed URL may still be crawled and may still be indexed.
Prerequisites
- Node 18 or later, for the reader and the local target below.
- curl 7.0 or later, for the one-line version in step 1.
- Google's guidance on qualifying outbound links for the three values and how to combine them.
- The served HTML. This reads what the server sent. Links inserted by scripts need the rendered DOM, which is How to view a page as googlebot.
Save this as links-server.cjs and start it with node links-server.cjs.
// node links-server.cjs -> http://127.0.0.1:8839
// /blog/ carries a mix of marked and unmarked outbound links.
// /comments/ carries a page-level nofollow that overrides every anchor on it.
const http = require('http');
const PORT = 8839;
const links = `
<a href="https://ads.example.com/deal" rel="sponsored">paid placement</a>
<a href="https://forum.example.com/post/1" rel="ugc">reader comment</a>
<a href="https://partner.example.com/" rel="nofollow">partner</a>
<a href="https://news.example.org/story">press coverage</a>
<a href="/pricing/" rel="nofollow">our pricing</a>
<a href="/docs/">our docs</a>
<a href="/login" rel="nofollow noopener">sign in</a>`;
const page = (head) =>
`<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<title>Blog</title>\n${head}\n</head>\n<body>\n${links}\n</body>\n</html>\n`;
http.createServer((req, res) => {
const head = req.url.startsWith('/comments/') ? '<meta name="robots" content="nofollow">' : '';
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end(page(head));
}).listen(PORT, '127.0.0.1', () => console.log(`links server on ${PORT}`));
Save this as link-rel.cjs.
// node link-rel.cjs <url> [--summary]
// Inventory of every anchor in the served HTML with its rel value and its scope.
const url = new URL(process.argv[2]);
const summary = process.argv.includes('--summary');
(async () => {
const html = await (await fetch(url)).text();
const meta = /<meta[^>]+name=["']?robots["']?[^>]*>/i.exec(html);
const page = meta && (/content=["']([^"']*)["']/i.exec(meta[0]) || [])[1];
console.log(`${url.href}\npage-level robots: ${page || '(none)'}`);
const rows = [];
for (const a of html.matchAll(/<a\s([^>]*)>/gi)) {
const href = (/href=["']([^"']*)["']/i.exec(a[1]) || [])[1];
if (!href || href.startsWith('#') || /^(mailto|tel|javascript):/i.test(href)) continue;
const rel = ((/rel=["']([^"']*)["']/i.exec(a[1]) || [])[1] || '').toLowerCase().trim();
const scope = new URL(href, url).host === url.host ? 'internal' : 'external';
rows.push({ rel: rel || '(none)', scope, href });
}
if (summary) {
const tally = {};
for (const r of rows) for (const t of r.rel.split(/[\s,]+/)) tally[`${r.scope} ${t}`] = (tally[`${r.scope} ${t}`] || 0) + 1;
for (const k of Object.keys(tally).sort()) console.log(` ${String(tally[k]).padStart(4)} ${k}`);
} else {
for (const r of rows) console.log(` ${r.rel.padEnd(17)} ${r.scope.padEnd(9)} ${r.href}`);
}
const ext = rows.filter((r) => r.scope === 'external');
console.log(`${rows.length} links: ${ext.length} external, ${rows.length - ext.length} internal, ` +
`${rows.filter((r) => /nofollow|sponsored|ugc/.test(r.rel)).length} carry nofollow, sponsored or ugc`);
})();
Steps
- Step 1.
Tally the
relattributes on the page with one command, to see the shape of the problem.curl -sS http://127.0.0.1:8839/blog/ | grep -o 'rel="[^"]*"' | sort | uniq -c1 rel="nofollow noopener" 2 rel="nofollow" 1 rel="sponsored" 1 rel="ugc"Five attributes on a page with seven links. The two links carrying no
relat all are invisible to this command, and they are the ones an audit is looking for. - Step 2.
Run the full inventory, which lists the unmarked links as well.
node link-rel.cjs http://127.0.0.1:8839/blog/http://127.0.0.1:8839/blog/ page-level robots: (none) sponsored external https://ads.example.com/deal ugc external https://forum.example.com/post/1 nofollow external https://partner.example.com/ (none) external https://news.example.org/story nofollow internal /pricing/ (none) internal /docs/ nofollow noopener internal /login 7 links: 4 external, 3 internal, 5 carry nofollow, sponsored or ugcRead the
internalrows first./pricing/is a page of this site asking not to be followed, which is the own-goal: an editor copied an anchor from an external link and kept its attribute./loginis the deliberate case, since a sign-in route has nothing to index. - Step 3.
Run the same inventory on a page that carries a page-level directive.
node link-rel.cjs http://127.0.0.1:8839/comments/http://127.0.0.1:8839/comments/ page-level robots: nofollow sponsored external https://ads.example.com/deal ugc external https://forum.example.com/post/1 nofollow external https://partner.example.com/ (none) external https://news.example.org/story nofollow internal /pricing/ (none) internal /docs/ nofollow noopener internal /login 7 links: 4 external, 3 internal, 5 carry nofollow, sponsored or ugcIdentical anchors, one different line.
page-level robots: nofollowapplies to every link in the document, so the per-link values no longer decide anything. A tool that reads only anchors reports this page as partly followed. - Step 4.
Run the summary form against a real page, to see what a site with no paid links looks like.
node link-rel.cjs https://developer.mozilla.org/en-US/docs/Web/HTTP --summaryhttps://developer.mozilla.org/en-US/docs/Web/HTTP page-level robots: (none) 18 external (none) 10 external noopener 538 internal (none) 2 internal noopener 568 links: 28 external, 540 internal, 0 carry nofollow, sponsored or ugcNot one of 568 links is nofollowed, and that is the correct result for a documentation site with no advertising and no user submissions. The 12
noopenervalues are a window security attribute sharing the samerelnamespace, and they say nothing about crawling.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| (none) on an external link that is paid | An advertisement is presented as an editorial link | Add rel="sponsored" in the template that renders the placement |
| (none) on an external link in user content | Comments and profiles are unmarked | Add rel="ugc" in the renderer, not per comment |
| nofollow on an internal row | The site is hinting against its own pages | Remove it unless the route is a login, a cart or a filtered view |
| noopener or noreferrer only | A window and referrer control, not a crawl hint | Leave it. It has no effect on link qualification |
| page-level robots: nofollow | Every link in the document is hinted, anchors included | Decide at the page level, then stop editing anchors |
Thresholds
Common mistakes
What to check next
- How to check if a page is noindex: the directive that does control indexing, which nofollow does not.
- How to check x-robots-tag: the header form of a page-level nofollow, which no anchor reveals.
- How to view a page as googlebot: the rendered DOM, for links a script adds after load.
- How to check for broken links on a website: the same inventory, resolved for status instead of
rel. - How to check if a staging site is indexable: where a stray nofollow is the smallest of the problems.
FAQ
How to check dofollow and nofollow links?
There is no dofollow value. A link without nofollow, sponsored or ugc is followed by default, so the inventory in step 2 answers both questions at once: the (none) rows are the followed links, and the rest are hinted.
When to use rel external or rel nofollow?
external says the target is outside the current site and is not part of Google's link qualification at all. Use sponsored for paid placements, ugc for user-submitted content, and nofollow when neither fits but you want no association with the target.
Does nofollow stop the linked page from being indexed?
No. It is a hint about one link, and since 2020-03-01 it is a hint for crawling and indexing rather than an instruction. The target can be discovered from a sitemap or from any other site. Use a noindex on the target page instead.
Should internal links ever carry nofollow?
Rarely. Login, logout, cart and faceted filter URLs are the usual cases, and a noindex on those pages is the stronger control. A nofollow on a normal internal route, as in step 2, is almost always a copy and paste accident.
Do sponsored and ugc replace nofollow?
They sit alongside it. All three can appear together in one space-separated rel value, and a link that already carries nofollow keeps working. Add the more specific value when the link is paid or user-submitted, since it describes the relationship rather than the intention.
Verified
Verified by Maks Vernycurl 8.21.0node 22.23.2
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
basic6 minpublished updated Maks Verny