JavaScript Links & Crawl Discovery: Does Googlebot Follow onClick?
Test which JavaScript link patterns Googlebot follows and which it ignores. Covers anchor href with JS handlers, onClick-only navigation, pushState, and SPA routing.
Googlebot's Web Rendering Service (WRS) executes JavaScript and builds a rendered DOM, but its link discovery algorithm does not interact with that DOM the way a human user does. Googlebot does not click buttons, does not trigger mouseover or mousedown events, and does not scroll the page. It discovers outbound links by parsing the rendered DOM for <a> elements with valid href attributes โ period. Any navigation pattern that requires a user interaction event (click, tap, hover) to reveal the destination URL is invisible to Googlebot's crawl graph.
This means the specific JavaScript link pattern your application uses determines whether Googlebot discovers and crawls your pages, or whether those pages become invisible orphans in the crawl graph. This guide documents the exact patterns Googlebot follows, the patterns it ignores, and the production-safe implementations that preserve both modern UX and crawlability.
How Googlebot's Link Discovery Pipeline Works
Googlebot's crawl process operates in two sequential phases, documented in Google's JavaScript SEO documentation:
Phase 1 โ Crawl: Googlebot fetches the raw HTML document. It extracts links from the initial HTML source before any JavaScript executes. These links enter the crawl queue immediately.
Phase 2 โ Render: The page enters the WRS rendering queue. WRS loads the page in a headless Chromium instance, executes JavaScript, and produces the final rendered DOM. Googlebot then extracts additional links from the rendered DOM that were not present in the initial HTML.
The critical constraint: in both phases, Googlebot extracts links exclusively from <a> elements with href attributes that resolve to valid URLs. It does not parse onclick handlers, addEventListener callbacks, or JavaScript variables that contain URL strings. It does not follow window.location assignments, history.pushState() calls, or router transitions unless those mechanisms also produce <a href> elements in the rendered DOM.
๐ก The Definitive Rule: If a URL does not appear as the
hrefattribute of an<a>element in the rendered DOM at the time Googlebot's WRS takes its snapshot, that URL is not discovered. No exceptions.
Link Patterns Googlebot Follows
Pattern 1: Standard Anchor Tags (Always Discovered)
The baseline crawlable link. Googlebot discovers these in both Phase 1 (raw HTML) and Phase 2 (rendered DOM):
<!-- โ
Always crawlable: standard anchor with href -->
<a href="/products/widget-pro">Widget Pro</a>
<!-- โ
Always crawlable: absolute URL -->
<a href="https://example.com/products/widget-pro">Widget Pro</a>
<!-- โ
Always crawlable: href with additional attributes -->
<a href="/products/widget-pro" class="card" data-id="123">Widget Pro</a>Pattern 2: Anchor Tags With JavaScript Event Handlers (Discovered If href Is Valid)
When an <a> element has both an href and a JavaScript handler, Googlebot extracts the href value and ignores the JavaScript:
<!-- โ
Crawlable: href is present โ Googlebot ignores the onclick -->
<a href="/products/widget-pro" onclick="trackClick('widget-pro')">
Widget Pro
</a>
<!-- โ
Crawlable: href is present โ Googlebot ignores the onmousedown -->
<a href="/products/widget-pro" onmousedown="recordEvent(event)">
Widget Pro
</a><!-- โ NOT crawlable: href is "#" โ the onclick provides the real URL -->
<a href="#" onclick="navigateTo('/products/widget-pro')">
Widget Pro
</a>
<!-- โ NOT crawlable: href is "javascript:void(0)" -->
<a href="javascript:void(0)" onclick="loadPage('/products/widget-pro')">
Widget Pro
</a>The pattern is clear: Googlebot reads the href attribute literally. If href contains a valid, resolvable URL, it's discovered. If href contains #, javascript:void(0), or an empty string, the link target is invisible to Googlebot regardless of what the JavaScript handler does.
Pattern 3: JavaScript-Rendered Anchor Tags (Discovered After WRS Rendering)
Links injected into the DOM by JavaScript frameworks (React, Vue, Angular, Svelte) are discoverable โ but only after WRS renders the page. This introduces a delay between crawl and render:
// โ
Crawlable: React renders <a href> elements in the DOM
export function ProductList({ products }) {
return (
<ul>
{products.map(product => (
<li key={product.id}>
<a href={`/products/${product.slug}`}>{product.name}</a>
</li>
))}
</ul>
);
}// โ
Crawlable: Next.js <Link> renders a standard <a> element
import Link from "next/link";
export function Navigation() {
return (
<nav>
<Link href="/products">Products</Link>
<Link href="/about">About</Link>
</nav>
);
}The WRS rendering queue can delay link discovery by hours or days. For time-sensitive pages, ensure links exist in the initial server-rendered HTML โ not exclusively in client-rendered JavaScript.
Link Patterns Googlebot Ignores
Pattern 4: onClick-Only Navigation (Never Discovered)
The most common crawlability failure in modern SPAs. When navigation happens entirely through JavaScript event handlers without an <a href> element:
<!-- โ Never crawlable: button with onclick navigation -->
<button onclick="window.location.href='/products/widget-pro'">
View Widget Pro
</button>
<!-- โ Never crawlable: div with click handler -->
<div class="product-card" onclick="navigateTo('/products/widget-pro')">
<h3>Widget Pro</h3>
<p>Our best seller</p>
</div>// โ Never crawlable: React component using onClick for navigation
export function ProductCard({ product }) {
const router = useRouter();
return (
<div
className="card"
onClick={() => router.push(`/products/${product.slug}`)}
>
<h3>{product.name}</h3>
</div>
);
}Googlebot sees the <button> or <div> in the DOM, but neither is an <a> element with an href attribute. The destination URL (/products/widget-pro) exists only inside the JavaScript handler, which Googlebot does not execute.
Fix: Wrap the interactive element in an <a> tag or replace it with an <a> styled as a button:
// โ
Fixed: Anchor tag wrapping the card โ crawlable AND clickable
export function ProductCard({ product }) {
return (
<a
href={`/products/${product.slug}`}
className="card"
onClick={(e) => {
e.preventDefault();
// Client-side navigation for SPA users
router.push(`/products/${product.slug}`);
}}
>
<h3>{product.name}</h3>
</a>
);
}This pattern gives Googlebot a real href to discover while preserving client-side SPA navigation for human users.
Pattern 5: history.pushState Without Anchor Elements (Never Discovered)
Single-page applications that update the URL bar via history.pushState() without rendering corresponding <a href> elements create URLs that exist in the browser history but never enter Googlebot's crawl graph:
// โ Not discoverable: pushState changes the URL but no <a> exists
function showProductDetails(productId: string) {
history.pushState(
{ productId },
"",
`/products/${productId}`
);
fetchAndRenderProduct(productId);
}Googlebot does not monitor history.pushState calls. The URL /products/${productId} never appears in the rendered DOM as an href attribute, so Googlebot never learns it exists.
Pattern 6: Lazy-Loaded Link Lists Behind Scroll or Click Events (Never Discovered)
Links that only load into the DOM when the user scrolls to a specific position or clicks a "Load More" button are invisible to Googlebot, because the WRS does not simulate scroll or click interactions:
// โ Links only appear after user scrolls โ invisible to Googlebot
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
fetchMoreProducts().then(items => setProducts(prev => [...prev, ...items]));
}
});
observer.observe(sentinelRef.current);
}, []);Fix: Server-render the initial batch of links and use pagination with crawlable <a href> navigation for subsequent pages. Lazy-load images and media, but never lazy-load the <a> elements themselves.
Pattern 7: Form-Based Navigation (Never Discovered)
Some sites use <form> submissions for navigation โ dropdown selectors that POST or GET to a destination URL:
<!-- โ Not crawlable: form-based navigation -->
<form action="/products" method="GET">
<select name="category" onchange="this.form.submit()">
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
</select>
</form>Googlebot does not submit forms. The URLs /products?category=electronics and /products?category=clothing are never discovered through this mechanism.
Fix: Add a parallel set of <a> links (in the footer, sidebar, or an HTML sitemap page) that link to every category:
<!-- โ
Crawlable fallback: standard anchor links to categories -->
<nav aria-label="Product Categories">
<a href="/products?category=electronics">Electronics</a>
<a href="/products?category=clothing">Clothing</a>
</nav>Testing Your Links for Googlebot Discoverability
Method 1: View Rendered Source in Chrome DevTools
Open the page in Chrome, right-click โ Inspect, and examine the Elements panel (which shows the rendered DOM, not the source HTML). Search for your target URL. If it appears inside an <a href="...">, Googlebot can discover it. If it only appears inside a JavaScript function or event handler attribute, it cannot.
# Automated check: extract all href values from the rendered page
# using Puppeteer/Playwright
npx playwright test --grep "link-discovery"// Playwright test: verify all navigation links are crawlable <a href>
import { test, expect } from "@playwright/test";
test("all product links are crawlable anchor elements", async ({ page }) => {
await page.goto("https://example.com/products");
// Get all elements that navigate users to product pages
const productLinks = await page.$$eval(
"[data-testid='product-link']",
elements => elements.map(el => ({
tag: el.tagName.toLowerCase(),
href: el.getAttribute("href"),
}))
);
for (const link of productLinks) {
expect(link.tag).toBe("a");
expect(link.href).toBeTruthy();
expect(link.href).not.toBe("#");
expect(link.href).not.toContain("javascript:");
}
});Method 2: Google's URL Inspection Tool
Submit the page URL in Google Search Console โ URL Inspection โ "Test Live URL." The rendered page shows exactly what Googlebot's WRS sees. Click "View Crawled Page" โ "More Info" to see the list of links Google discovered on the page. Any missing links indicate a crawlability gap.
Method 3: Google's Rich Results Test
The Rich Results Test renders the page using the same WRS pipeline. The rendered HTML output shows the final DOM state from which Googlebot extracts links. Search for your target URLs in the rendered output.
Method 4: Fetch as JavaScript-Disabled User-Agent
Some link discovery issues are visible by comparing the page with and without JavaScript:
# Fetch without JavaScript โ shows Phase 1 (raw HTML) links only
curl -s "https://example.com/products" | grep -oP 'href="[^"]*"' | sort -u
# Compare against full rendered DOM links (requires headless browser)
node -e "
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://example.com/products', {waitUntil: 'networkidle0'});
const links = await page.$$eval('a[href]', els => els.map(e => e.href));
console.log(links.join('\n'));
await browser.close();
})();
" | sort -uIf the headless browser finds links that curl does not, those links require WRS rendering for discovery โ introducing the rendering queue delay.
Framework-Specific Crawlability Patterns
Next.js / React
Next.js <Link> component renders a standard <a> element in the DOM. Always use <Link> for internal navigation:
// โ
Next.js Link renders a crawlable <a> element
import Link from "next/link";
<Link href="/products/widget-pro">Widget Pro</Link>
// Rendered DOM: <a href="/products/widget-pro">Widget Pro</a>// โ useRouter.push() alone is not crawlable
import { useRouter } from "next/navigation";
const router = useRouter();
<button onClick={() => router.push("/products/widget-pro")}>
Widget Pro
</button>
// Rendered DOM: <button>Widget Pro</button> โ no hrefVue.js / Nuxt
Vue Router's <router-link> renders an <a> element by default:
<!-- โ
Crawlable: router-link renders <a href> -->
<router-link to="/products/widget-pro">Widget Pro</router-link>
<!-- Rendered DOM: <a href="/products/widget-pro">Widget Pro</a> --><!-- โ Not crawlable: programmatic navigation without <a> -->
<div @click="$router.push('/products/widget-pro')">Widget Pro</div>Angular
Angular's routerLink directive on an <a> element produces a crawlable link:
<!-- โ
Crawlable: routerLink on anchor element -->
<a [routerLink]="['/products', 'widget-pro']">Widget Pro</a>
<!-- Rendered DOM: <a href="/products/widget-pro">Widget Pro</a> --><!-- โ Not crawlable: routerLink on non-anchor element -->
<div [routerLink]="['/products', 'widget-pro']">Widget Pro</div>
<!-- Rendered DOM: <div>Widget Pro</div> โ Googlebot ignores -->How BugViso Detects Non-Crawlable Link Patterns
BugViso's multi-page site crawl engine runs through a headless Chromium browser, executing JavaScript and building the rendered DOM exactly as Googlebot's WRS does. The rendered-link crawl discovers URLs from the fully rendered DOM โ navbar, sidebar, footer, and body โ including links injected by JavaScript. This means it discovers the same <a href> elements Googlebot would find, and misses the same onClick-only navigation that Googlebot misses.
When BugViso's internal link graph engine builds the site-wide link map, it surfaces orphan pages โ pages with zero inbound internal links. If product pages exist in your sitemap but receive no <a href> links from the rendered DOM of any crawled page, they appear as orphans in BugViso's report. This is the primary diagnostic signal for onClick-only navigation: the pages exist and are accessible by URL, but no rendered link points to them.
BugViso's JavaScript console error audit captures runtime JS exceptions with source file, line number, and stack trace. JavaScript errors that prevent link rendering โ a TypeError in a product list component, a failed API call that leaves the list empty โ surface as console errors in the BugViso report, directly correlating with missing <a href> elements in the rendered DOM.
Common Traps and Edge Cases
Trap: <a> elements with dynamically set href via JavaScript. If JavaScript sets the href attribute after the initial render but within the WRS execution window (~5 seconds), Googlebot typically discovers the link. If the href is set after an async API call that takes longer than the WRS timeout, the link is missed.
// โ ๏ธ Risky: href set asynchronously โ depends on API response time
useEffect(() => {
fetch("/api/featured-product")
.then(res => res.json())
.then(data => {
linkRef.current.href = `/products/${data.slug}`;
});
}, []);
return <a ref={linkRef}>Featured Product</a>;Trap: Client-side redirects via window.location. A page that renders and then immediately redirects via window.location.href = "/new-page" may or may not be followed by Googlebot. The WRS processes redirects differently than standard HTTP redirects. Server-side 301/302 redirects are reliable; client-side window.location redirects are not.
Trap: Hash-based routing (#/products/widget). URLs using hash fragments for routing (e.g., https://example.com/#/products/widget) are treated by Googlebot as a single URL (https://example.com/). Everything after # is stripped. Hash-based routing makes every "page" in your SPA invisible to Googlebot. Use history.pushState-based routing (path-based URLs) instead.
Trap: Shadow DOM encapsulation. Links inside Shadow DOM trees (web components using attachShadow({ mode: "open" })) are currently not reliably discovered by Googlebot. Google has stated they render Shadow DOM, but link extraction from shadow roots is inconsistent in practice. Keep navigation links in the light DOM.
Frequently Asked Questions
Does Googlebot execute addEventListener("click") handlers?
No. Googlebot's WRS does not simulate click events, mouse events, or keyboard events. It renders the page to produce the final DOM state but does not interact with it. Any navigation logic inside event listeners is never executed.
Can Googlebot discover links inside <iframe> elements?
Googlebot treats <iframe> content as a separate page. It may crawl and index the iframe source URL independently, but links inside the iframe do not contribute to the parent page's link graph. Navigation links should always be in the main document DOM, not inside iframes.
Does Googlebot follow rel="nofollow" links for crawl discovery?
Yes โ rel="nofollow" is a hint that Google may or may not follow. Since 2019, Google treats nofollow as a hint rather than a directive. Googlebot may still crawl and index the destination URL; nofollow only suggests that link equity should not be passed. For preventing crawl discovery, use robots.txt Disallow or avoid linking to the URL entirely.
How long does the WRS rendering queue take?
Google's documentation does not specify exact queue times, but empirical observations suggest the rendering queue can delay link discovery by hours to days after the initial crawl. High-authority, frequently-updated sites tend to have shorter rendering queue times. New or low-traffic sites may wait days for WRS rendering.
Should I server-side render all links for faster discovery?
Server-side rendering (SSR) ensures links are present in the Phase 1 (raw HTML) crawl, eliminating the WRS rendering queue delay. For critical navigation links (main nav, footer, category links), SSR is strongly recommended. For supplementary links (related articles, recommended products), client-side rendering is acceptable as long as the links render as <a href> elements in the final DOM.
Does Google's mobile-first indexing affect JavaScript link discovery?
Yes. Google crawls with a mobile user-agent by default (Googlebot smartphone). If your mobile layout hides navigation behind a hamburger menu that requires a click to open, and the menu links are only injected into the DOM after the click event, those links are never discovered. Ensure the full navigation DOM is rendered on page load, even if it's visually hidden via CSS โ CSS display: none hides elements visually but Googlebot still reads them from the DOM.
Conclusion
Googlebot discovers links exclusively from <a href> elements in the rendered DOM โ onClick handlers, pushState calls, and form submissions are invisible to the crawl graph, and the resulting orphan pages are exactly what BugViso's internal link graph audit surfaces by crawling through the same headless Chromium rendering pipeline and mapping which pages receive zero inbound <a href> links from any other crawled page.
See where your site stands
Run a free BugViso audit for SEO, speed, accessibility and AI search readiness โ with fixes you can ship today.