The JavaScript and rendering guide in this section covers how Google executes JavaScript to see a page's final content. This guide focuses on a different, earlier problem: in a SPA (Single Page Application), switching "screens" doesn't always mean switching URLs, and if the URL doesn't change, there's nothing new for a crawler to discover, visit or index separately, no matter how well the content renders once you get there.
This guide deals specifically with routing: how to implement it correctly with the History API so every SEO-relevant view has its own real URL, and the routing mistakes (not rendering mistakes) that make entire sections of a SPA invisible for SEO even when the JavaScript runs without errors.
The root problem: navigation without a URL change
A SPA loads a single initial HTML document and then dynamically swaps the page's content with JavaScript as the user navigates, without requesting a new document from the server. If that internal navigation doesn't also update the URL shown in the address bar, from a crawler's perspective nothing has happened: it still sees the same URL, the same document, and has no reason to revisit it thinking there's new content to discover, because there's no new URL pointing to that content.
This isn't unique to poorly built SPAs: it's the default behavior of routing purely in-memory with JavaScript (changing which component renders) without touching the URL. Any modern SPA framework solves this with a dedicated router (React Router, Vue Router, Angular Router), but the router has to be configured and used correctly; installing it isn't enough.
The History API: pushState and replaceState as the technical foundation
SPA routers rely on the browser's History API, specifically the history.pushState() and history.replaceState() methods, which let you change the URL shown in the address bar (and add an entry to the browsing history) without triggering a full page reload:
// Navigating to a product view inside the SPA
history.pushState({ productId: 48291 }, '', '/products/nordic-oak-chair');
When this is implemented correctly, every view with its own value (a product page, an article, a category) has a real URL, directly visitable by typing it into the browser, shareable, and therefore independently discoverable and crawlable by Google. When it isn't implemented (the router only changes an internal React or Vue state without calling pushState), that same view is effectively invisible to anyone other than a user manually clicking through the session.
Real links (<a href>) versus JavaScript click handlers
A very common routing mistake in SPAs isn't in the router itself, but in how the links that trigger it are built. A link implemented as an element with no real href that only responds to an onClick JavaScript event isn't a crawlable link: Googlebot discovers new URLs mainly by following href attributes on <a> elements, not by executing arbitrary event handlers to see where they lead.
<!-- Not crawlable: no href, relies on JS to navigate -->
<span onclick="navigateTo('/products/nordic-chair')">View chair</span>
<!-- Crawlable: real href, the router intercepts the click for SPA navigation -->
<a href="/products/nordic-chair" onclick="handleClick(event)">View chair</a>
The second version is what mature SPA routers use correctly: the href points to the real URL (so a crawler, a user without JavaScript, or someone opening the link in a new tab all land on a functional site), and JavaScript intercepts the click with event.preventDefault() to avoid the full reload and navigate internally instead, while keeping the URL synced via pushState.
Nested routes and dynamic parameters: the server needs to know how to answer too
A SPA with client-side routing needs the server to also know what to respond when someone (a crawler, a user reloading the page, someone arriving from an external link) directly requests one of those internal URLs, like /products/nordic-oak-chair, without having navigated through the app first. If the server isn't configured to serve the SPA's main HTML document for any route matching the router's pattern, that direct request returns a real 404, even though the same URL works perfectly when reached by navigating from inside the app:
# Apache: serve index.html for any route that isn't a real file
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.html [L]
This configuration (common on SPAs deployed as static files) is what lets a crawler, which always requests URLs directly without going through the app's internal navigation, get a valid response instead of a 404, allowing the JavaScript to load and the SPA's internal router to recognize the route and render the correct content for that specific URL.
Loading states and content that "appears" later: timing matters
Even with routing solved, a SPA usually shows a loading state first (a skeleton, a spinner, an empty container) while it makes an API request to fetch that view's real data. A crawler that executes JavaScript can end up seeing the final content, but only if it waits long enough before capturing the rendered HTML; if the API request takes longer than the crawler is willing to wait, what gets captured is the empty loading state, not the real content.
This doesn't have a purely routing-based fix: keeping data requests fast for SEO-relevant views helps mitigate it, and for critical cases (product pages or main content pages), it's worth considering server-side rendering or static pre-rendering instead of fully relying on the crawler patiently waiting on a slow API.
Sitemap and internal linking: don't rely solely on navigation-based discovery
Even with technically correct, directly accessible URLs, it's still good practice to explicitly declare every SEO-valuable SPA route in an XML sitemap, rather than assuming Google will discover them purely by following internal links dynamically generated by JavaScript. Real internal linking (with href, not just click handlers) remains the strongest signal of which pages matter within the site, so a clear navigation architecture inside the SPA remains just as relevant for SEO as on a traditional site.
Frequently asked questions
Is server-side rendering (SSR) mandatory for a SPA to be indexable?
It isn't mandatory if History API routing is well implemented and Google manages to render the content in time, but SSR or pre-rendering reduces the dependency on JavaScript crawling working perfectly on every visit, and usually also improves perceived speed for real users.
Why does a URL in my SPA work when navigating inside the app but return 404 if pasted directly into the browser?
Because the server isn't configured to serve the SPA's main document for any route matching the router's pattern; it only responds correctly to the root route or to files that physically exist.
Do modern SPA frameworks solve this automatically?
The router (React Router, Vue Router) handles the History API part if used correctly, but server configuration to serve the HTML document on any route, and using real links with href, remain the responsibility of whoever implements the site.
Does a hash in the URL (#/products/chair) work the same as a History API route?
Not reliably. Crawlers have historically treated the fragment after # as part of the same URL, not as a distinct route, so that routing pattern (common in older SPAs) is far less indexable than real routes built with pushState.
How do I check whether Google is seeing my SPA's real content?
Search Console's URL inspection tool lets you see the rendered HTML exactly as Google captures it for a specific URL, which reveals whether the dynamic content is present or whether only the empty loading state gets captured.