How to Do SEO for Single Page Applications (SPA)
On this page
- The core SPA problem
- Two waves, and why CSR is unreliable
- Diagnosis: two tests for two failure modes
- The client-side routing pitfall
- Remediation hierarchy
- Metadata, links, and the things that still break with SSR
- Confirm the fix, do not assume it
- Recovery expectations after the fix
- Frequently Asked Questions
- Sources
- Related posts:
A single-page application fails at SEO when its content, and sometimes its URLs, do not exist until JavaScript runs. Google crawls the raw HTML first, and if that HTML is an empty shell with a loading spinner, that shell is what gets evaluated. Rendering happens on a second, delayed pass that is not fast or guaranteed. The reliable fix is to make sure real content and a valid server response exist in the initial HTML for every route, using server-side rendering (SSR) or static-site generation (SSG). Treat client-side rendering as inadequate for SEO and dynamic rendering as a legacy workaround, not a target.
The trap is that “Google can render JavaScript now” is true and misleading at the same time. Google does execute JavaScript, but operationally it is unreliable for content you need indexed: the render queue is delayed, resource-limited, and time-bounded. Designing your indexability around it is designing around a best-effort process you do not control.
The core SPA problem
A typical SPA ships a near-empty HTML document and a JavaScript bundle. The browser runs the bundle, fetches data, and paints the interface. A search crawler that reads only the initial HTML sees the empty document. The content the user eventually sees, your headings, body copy, links, the things that earn rankings, are not there at crawl time.
So the first failure mode is straightforward: if rendering does not complete, or completes incompletely, Google indexes a shell or a spinner. You can have a page full of valuable content and have Google believe the page is blank.
Two waves, and why CSR is unreliable
Google processes JavaScript pages in two waves. First it crawls and indexes the raw HTML. Then, when rendering resources are available, it executes the JavaScript through an evergreen Chromium-based rendering service and indexes whatever that produces. The gap between the two waves can be meaningful, and the render pass operates under time and resource limits.
For client-side rendering (CSR), this means your most important content is always dependent on the second wave landing successfully and promptly. Sometimes it does. The problem is that “sometimes” is not a foundation for content you need ranked. Anything genuinely SEO-critical should not be gated behind JavaScript execution that may be delayed or truncated.
Diagnosis: two tests for two failure modes
SPAs fail in two distinct ways, and they call for two different checks.
Content-not-rendered. Use URL Inspection in Google Search Console and run the live test. Compare the rendered HTML and the screenshot Google produces against what a user sees in a browser. If the rendered output is a loading spinner, a skeleton, or missing the main content, the second wave is not capturing your page. That is a rendering failure.
URL-not-resolving. Disable JavaScript in your browser and navigate directly to an interior URL, not by clicking through the app, but by pasting the deep link and loading it cold. Many SPAs handle navigation client-side: the router only works after the bundle loads, so a direct request to an interior route returns a blank page or a 404 from the server. That is a routing failure, and it is separate from whether content renders.
You need both tests because passing one does not imply passing the other. A page can render fine when reached through the app yet return nothing on a cold direct load, and vice versa.
The client-side routing pitfall
This deserves its own emphasis because it is easy to miss. In a client-side-routed SPA, the server may only know about the root path. When the router takes over in the browser, interior URLs work. But Google (and any direct visitor or shared link) requests interior URLs from the server first. If the server responds with a 404 or an empty document for those paths before the router has a chance to run, the route is effectively non-existent to a crawler.
The requirement is simple to state: every URL you want indexed must return the actual app or its content from the server on a direct request, with a valid 200 status, before any client-side routing happens. If a direct request to a deep link does not return that page’s content, no amount of content optimization will help, because the URL never resolves to anything indexable.
Remediation hierarchy
Fix this in order of durability.
- Server-side rendering (SSR). The server renders the page’s HTML, including its content, on each request and sends a complete document. Google gets real content in the first wave and never depends on executing your JavaScript to see it.
- Static-site generation (SSG). Pages are pre-rendered to static HTML at build time. For content that does not change per request, this is the simplest and fastest path to fully indexable HTML.
- Pre-rendering as a stopgap. Pre-rendering services or platform pre-render options generate static snapshots for crawlers. Treat this as a temporary bridge while you move to SSR or SSG, not as the destination.
- Dynamic rendering: legacy only. Google no longer recommends dynamic rendering. It relies on detecting bots by user agent and serving them a separately rendered version, which is brittle to maintain and increasingly leaves out crawlers you did not list. Use it, if at all, as a short-lived migration workaround, never as a new build’s strategy. SSR or SSG first.
Frameworks come up constantly here, but the architecture-level principle is what matters: content and a valid response must exist before JavaScript runs. The specific framework you use to achieve SSR or SSG is an implementation detail downstream of that principle.
Metadata, links, and the things that still break with SSR
Server-side rendering fixes the empty-shell problem, but it does not automatically fix everything an SPA tends to get wrong. Per-route metadata is the first thing to verify. Many SPAs set a single static title and description in the base HTML and update them client-side as the user navigates. If those updates happen only after JavaScript runs, every route can crawl with the same generic title until the second wave lands, which undercuts how distinctly each page can rank. With SSR, make sure the title, meta description, and canonical tag are rendered server-side for each route, reflecting that route’s actual content.
Links are the second. A crawler discovers and follows real anchor elements with href attributes pointing at full URLs. Navigation wired only to click handlers that call the router programmatically, with no underlying href, gives the crawler nothing to follow, so interior pages may never be discovered through internal linking. Use genuine anchor tags for navigation you want crawled, and let the router enhance them rather than replace them.
Confirm the fix, do not assume it
After deploying SSR or SSG, verify rather than trust the framework’s claims. View the page source of a representative route, with JavaScript disabled, and confirm the main content, the route-specific title, and the internal links are present in that raw HTML. A framework configured for SSR can still fall back to client rendering on specific routes through a misconfiguration, so spot-check several route types, not just the homepage.
Recovery expectations after the fix
Fixing rendering does not flip rankings overnight. Google has to recrawl the affected URLs and process the now-visible content. Speed it along by requesting indexing for your most important pages through URL Inspection, and confirm via the live test that the rendered HTML now contains real content. Then give it time: re-evaluation follows the normal recrawl cadence, and pages that were previously seen as empty have to be re-assessed on their actual content.
Frequently Asked Questions
Can’t Google just render my JavaScript?
It can execute JavaScript, but on a delayed, resource-limited second pass that is not guaranteed to capture everything. Relying on it for content you need indexed is unreliable. Put SEO-critical content in the server-rendered HTML so it does not depend on that pass.
My pages render fine when I click through the app. Why aren’t they indexed?
Likely a routing failure rather than a rendering one. Test by loading an interior URL cold, with JavaScript disabled, pasted directly into the address bar. If the server returns a blank page or 404, the route does not resolve for crawlers even though in-app navigation works.
Is dynamic rendering a valid fix?
Not as a target. Google has stepped back from recommending it and treats it as a legacy migration workaround. The technique relies on fragile user-agent detection. Use SSR or SSG so every visitor and crawler gets the same complete HTML.
Sources
Google Search Central, Fix Search-related JavaScript problems: https://developers.google.com/search/docs/crawling-indexing/javascript/fix-search-javascript
Google Search Central, Understand the JavaScript SEO basics: https://developers.google.com/search/docs/crawling-indexing/javascript/javascript-seo-basics
Google Search Central, Dynamic rendering as a workaround: https://developers.google.com/search/docs/crawling-indexing/javascript/dynamic-rendering