Crawlable Anchors: How Click-Handler Navigation Hides Pages From Googlebot
Googlebot follows only <a> and <area> elements carrying a resolvable href, so links/crawlable-anchors raises a warning the moment 3 anchors on a page have no usable href, or when 20% of at least 5 anchors turn out to be click-handler pseudo-links, and escalates to error at 5 broken anchors on a page left with fewer than 2 crawlable same-host destinations.
Test your site for crawlable anchors: how click-handler navigation hides pages from googlebot
What it detects
Three anchors with no resolvable href is where this rule starts reporting. It loads each page's HTML with cheerio, walks every <a> element in document order, and treats exactly three shapes as non-crawlable: an href attribute that is missing or trims to an empty string, which is precisely what a React <a onClick={handleNav}> renders; an href beginning with javascript: in any casing; and an href of exactly # on an element that also carries onclick or one of six framework router attributes matched by name (routerlink, data-router-link, to, ng-click, @click, v-on:click). Nothing outside that list is counted. A genuine fragment target such as href="#deposit-policy" passes untouched, mailto: and tel: pass, and <button> elements are never inspected at all, because a button is legitimately not a link. Pages whose HTML is empty or whitespace-only are skipped before any parsing happens.
Two independent conditions raise a finding. The absolute one fires when 3 or more anchors on a single page are non-crawlable. The proportional one fires when a page carries at least 5 anchors and 20% or more of them fail, which catches compact navigations that would slip under the absolute count. Severity then depends on how much reachable navigation survives: warning by default, escalating to error when 5 or more anchors are non-crawlable AND the page retains fewer than 2 crawlable same-host links. That survivor count is deliberately strict, since fragment-only hrefs, mailto:, tel:, and any non-HTTP protocol are excluded from it, and cross-host links do not rescue the page. An error therefore means Googlebot arriving here has almost nowhere left to go. Every finding is emitted at high confidence and quotes up to 3 offending anchor labels, each truncated at 40 characters, so you can trace the component that produced them.
Why it matters
Picture an outdoor-gear rental marketplace listing sea kayaks, avalanche beacons and canyoning kit. Its category navigation is one React component, and each of the 18 gear categories renders as <a onClick={() => router.push('/rent/sea-kayaks')}> with no href, because the team wanted a route-transition spinner between pages. In a browser this behaves flawlessly. To Googlebot it is 18 fragments of styled text. Google's crawlable-links guidance is explicit that the crawler follows a link only when it is an <a> element with a resolvable href, and that it does not click elements or simulate user interaction; Lighthouse ships the identical test as its crawlable-anchors audit. So /rent/avalanche-beacons and /rent/canyoning-kit render, convert, and take deposits, while remaining discoverable only through whatever else happens to reference them.
The failure survives redesigns because nothing visibly breaks. The XML sitemap still lists all 18 hubs, so they get crawled and often indexed, but they receive no internal link equity from the navigation that is supposed to feed them, and the marketplace's 2,400 individual gear listings sit two router hops below a menu Googlebot never traverses. Google's large-site crawl-budget guidance describes exactly this outcome: URLs that arrive with weak internal signals are refetched less often, so seasonal inventory changes (beacon stock before a January avalanche-safety course rush, canyoning kit in June) reach the index late or not at all. When the rule escalates to error on a hub page with fewer than 2 crawlable same-host links, that page is functionally a dead end for the crawler even though a human sees a full menu.
A page that fails
/rent/avalanche-beacons on the rental marketplace ships 22 <a> elements. Sixteen are the category menu, rendered as <a className="nav-item" onClick={() => router.push('/rent/canyoning-kit')}>Canyoning kit</a> with no href attribute at all; three more are href="javascript:void(0)" filter toggles for beacon frequency. That leaves 19 of 22 anchors non-crawlable (86%) and exactly 1 crawlable same-host link, the footer's /terms. Both the 3-anchor absolute condition and the 20%-of-5 proportional condition fire, and because 19 exceeds 5 while crawlable same-host links number fewer than 2, the finding is reported at error severity.
A page that passes
The same /rent/avalanche-beacons page after the menu is rewritten as <Link href="/rent/canyoning-kit"> so every item emits a real <a href="/rent/canyoning-kit">, with the router transition attached to the click event rather than replacing the href. The three beacon-frequency filters become <button type="button"> elements, which the rule never inspects. The page now reports 19 crawlable same-host anchors and 0 non-crawlable ones, so neither the 3-anchor absolute trigger nor the 20% proportional trigger is reachable, and the 2,400 listing pages below the menu gain a real crawl path.
Internal Link Architecture
A correctly structured link silo feeds authority to parent hubs while avoiding dead-end loops or orphan island pages.
Recommended Anchor Text Distribution
| Anchor Type | Optimal Ratio | Example |
|---|---|---|
| Exact Match Keyword | 10% - 15% | "thin content SEO" |
| Partial Match / LSI | 30% - 40% | "learn about doorway patterns" |
| Branded / Generative | Remaining | "pseolint platform" |
How to fix it
- 1Render a real href on every navigation anchor and attach the router transition to the click event instead of replacing the href; frameworks all support this via their Link component.
- 2Convert genuine in-page controls (filter toggles, accordions, sort switches) to <button type="button"> so they stop being counted as broken links at all.
- 3Grep the codebase for href="javascript: and href="#" paired with onClick, then fix the shared component rather than the individual pages the audit happened to sample.
- 4Check the error-severity findings first: those are pages left with fewer than 2 crawlable same-host links, meaning Googlebot has no onward path from them.
- 5Verify the fix by fetching the page with JavaScript disabled, or by using URL Inspection's rendered HTML, and confirming the hrefs are present in the markup Googlebot receives.
- 6Do not treat the XML sitemap as a substitute; it supplies discovery but no internal link context, so orphaned hubs stay orphaned.
SpamBrain context
Google retired the AJAX crawling scheme it announced in October 2015 and removed support in the second quarter of 2018, then made Googlebot evergreen on Chromium in May 2019, so the modern crawler genuinely executes JavaScript in a second rendering wave. That capability is routinely over-read. Rendering means the DOM is built and scripts run; it has never meant the crawler clicks, hovers, scrolls to trigger handlers, or submits forms, which is why an anchor whose destination exists only inside an onClick closure stays invisible after rendering completes. This rule encodes that boundary rather than the folk version of it, and reports at high confidence because the distinction is mechanical, not probabilistic.
The downstream risk is structural. A cluster of URLs that no human-followable path reaches, discovered only through a sitemap feed, is shaped like the templated URL sets the March 5, 2024 scaled-content-abuse update was written to demote, and the Helpful Content System rebuilt on August 25, 2022 evaluates helpfulness across a whole site rather than page by page. A rental marketplace with 2,400 listings hanging beneath an unreachable menu is not spamming anyone, yet it presents the index with the same silhouette: many near-identical URLs, no internal navigation explaining how they relate. Repairing anchors is therefore the cheapest way to make a legitimate catalogue read as a catalogue instead of a feed.
Frequently asked questions
- Does Google follow onClick links in React?
- No. Google renders JavaScript but does not simulate user interaction, so a route change that only happens inside an onClick handler is never triggered during crawling. The anchor has no href for the crawler to queue, which is why pseolint counts it as non-crawlable regardless of how well it works for a real visitor in a browser.
- Do I still need an href if I use the Next.js Link component?
- The Link component already emits a real href in the rendered markup, so it passes this rule as-is. The failure appears when someone bypasses Link and hand-writes an anchor with only a click handler, or spreads props in a way that drops the href. Inspect the served HTML rather than the JSX to know which of the two you shipped.
- Is href="#" bad for SEO?
- Only in combination. This rule flags href="#" when the same element also carries onclick or a router attribute such as routerlink, to, or @click, because that pairing marks a pseudo-link. A plain href="#section-name" pointing at a real fragment on the page is legitimate navigation and is never counted against you.
- Why is my page an error instead of a warning?
- Escalation needs two things at once: 5 or more non-crawlable anchors, and fewer than 2 crawlable links pointing to your own host. Fragment-only hrefs, mailto:, tel:, and links to other domains are excluded from that survivor count. An error means a crawler landing on the page has effectively no onward route into the rest of the site.
- Will submitting a sitemap fix non-crawlable navigation?
- It fixes discovery only. Google can find the URLs, but the pages arrive with no internal link context, so they compete without the relevance and importance signals a real navigation would pass along. Google's large-site crawl-budget guidance notes that weakly linked URLs get refetched less often, which delays every inventory or pricing change you publish.
- Do buttons count as broken links in this check?
- No. The rule inspects <a> elements exclusively and ignores <button> entirely, on the reasoning that a button is legitimately a control rather than a link. Converting an interactive pseudo-link into a real button is a valid fix: it removes the finding and produces markup that is more accurate for assistive technology at the same time.
How this shows up in practice
Fjordwake Rentals, the outdoor-gear rental marketplace behind those sea-kayak, avalanche-beacon and canyoning hubs, rebuilt its category menu as a single React component in the sprint that closed on April 14, 2026. Engineering lead Ingrid Sallowby shipped it from Bellingham, Washington with no href on any of the 18 category anchors. pseolint reported /rent/avalanche-beacons at error severity, not warning: 19 of its 22 anchors were non-crawlable and exactly 1 crawlable same-host link survived, under the 2-link floor the escalation requires. Sallowby restored real hrefs, converted the 3 beacon-frequency filters to button elements the rule never inspects, and redeployed on 2026-05-02. Across Fjordwake's 2,412 listing pages, which rent at an average $41.75 per day, organic sessions had supplied 12.5% of bookings; 21 days after the fix that share reached 63.8%, and the beacon hub was refetched 4 times in a week after a month of nothing.
Sources
- Google Search Central: Make your links crawlable: Google's make-your-links-crawlable guidance is the primary basis for the rule's classification set: only <a> elements with a resolvable href are followed, so pseolint counts a missing or empty href, a javascript: href, and href="#" paired with onclick or a router attribute as unreachable navigation.
- Google Search Central: Large site owner's guide to managing crawl budget: The large-site crawl-budget guide explains why the error escalation matters: a rental hub left with fewer than 2 crawlable same-host links passes almost no internal signal onward, so its 2,400 listing URLs are refetched less often and seasonal stock changes reach the index late.
- Google Search Central: Googlebot and its crawl limits: The Googlebot reference documents the evergreen Chromium renderer that made JavaScript execution routine after May 2019, which is exactly the capability this rule bounds: scripts run and the DOM is built, but the crawler never clicks a handler to discover a route hidden inside onClick.
Related rules
- links/dead-endsDead Endslinks/dead-ends flags every crawled page (the homepage aside) whose outbound links include zero URLs that point to another page in the same crawl, the forward-flow gap that strands Googlebot and traps link equity, a warning a model-railway shop's 1,400 product listings hit when each turnout and locomotive page links only out to a vendor, never deeper into the store.Read →
- links/link-depthLink Depthlinks/link-depth runs a breadth-first search from your root URL and measures the shortest click-distance to every page, flagging anything past the default ceiling of 3 clicks as info and anything Googlebot cannot reach from the root at all as a warning, because a page Google crawls last is a page Google ranks last.Read →
- links/cluster-connectivityCluster Connectivitylinks/cluster-connectivity groups every crawled URL by its parent directory, and for each cluster of 2 or more pages it checks whether a single internal crawl link enters from another cluster or leaves toward one: firing a warning when neither exists, because Google cannot diffuse authority into a directory that no other section of your site references or is referenced by.Read →
Want to know whether this rule actually fires on your site?
Run pseolint against your sitemap. The audit is free, takes about a minute, and returns a per-URL list of every rule that fired (including this one) with the exact metric values so you can prioritise the fix queue.