---
title: "SPA SEO: How to Get Single Page Applications Indexed"
description: "Search engines and AI crawlers see an empty page when they hit your SPA. Diagnose the problem and fix it with SSR, prerendering, and crawlable URLs."
canonical_url: "https://nuxtseo.com/learn-seo/spa-seo"
last_updated: "2026-07-16"
---

<key-takeaways>

- Google renders JavaScript, but AI crawlers (GPTBot, ClaudeBot, PerplexityBot) fetch your HTML and never execute JS. Content that only exists after hydration is invisible to them
- Server-side rendering or prerendering fixes indexing delays, AI visibility, and Core Web Vitals in one move
- Every page needs a unique URL (no `#` routing), real `<a href>` links, and metadata in the initial HTML response

</key-takeaways>

A single page application ships one HTML shell and builds every page in the browser with JavaScript. Users get instant navigation. Crawlers get an empty `<div id="app"></div>`, because the initial HTML response, the only thing most bots read, contains none of your content.

This guide is framework-agnostic: the same fixes apply whether you build with [React](https://react.dev), Vue, [Angular](https://angular.dev), [Svelte](https://svelte.dev), or vanilla JS. Working with Vue specifically? The [Vue SPA guide](/learn-seo/vue/spa) covers the same ground with Vue tooling.

## Why SPAs Struggle in Search

### Google renders JavaScript, eventually

Google processes pages in [three phases: crawling, rendering, and indexing](https://developers.google.com/search/docs/crawling-indexing/javascript/javascript-seo-basics). A server-rendered page gets indexed from the initial HTML. A client-rendered page waits in a render queue until Googlebot can execute its JavaScript.

Google publishes no timing guarantee for that queue, only that a page "may stay on this queue for a few seconds, but it can take longer than that". On large sites or sites with frequent updates, the delay compounds: your content ships, but the indexed version lags behind.

### AI crawlers skip JavaScript entirely

[Vercel measured AI crawler behavior](https://vercel.com/blog/the-rise-of-the-ai-crawler) across their network: GPTBot, ClaudeBot, and PerplexityBot all download JavaScript files but never execute them. Whatever your server sends in the initial response is all they see. The one exception is [Gemini](https://gemini.google.com), which crawls through Googlebot's rendering infrastructure and gets the fully rendered page.

If your content sits behind a JS execution wall:

- No citations in [ChatGPT](https://chatgpt.com) or [Perplexity](https://perplexity.ai)
- No inclusion in Google AI Overviews sourced from non-Google crawls
- No presence in training data that could surface your brand in answers

Note that [OpenAI runs three separate bots](https://developers.openai.com/api/docs/bots): GPTBot (training), OAI-SearchBot (ChatGPT search index), and ChatGPT-User (live page fetches when a user asks about your site). You can block training while staying visible in search, but all three read only your initial HTML.

### Hydration hurts Core Web Vitals

A client-rendered SPA locks the main thread while it downloads, parses, and executes its bundle. A user who clicks during that window gets no response, which shows up as poor Interaction to Next Paint (INP). Google treats Core Web Vitals as a [page experience signal](https://developers.google.com/search/docs/appearance/core-web-vitals) that its ranking systems reward, as a tiebreaker between similarly relevant pages. Aim for INP under 200ms.

The bar is beatable: per the [2025 Web Almanac](https://almanac.httparchive.org/en/2025/performance), only 48% of mobile origins pass all three Core Web Vitals, and LCP is the metric that fails most.

### Soft 404s

A traditional server returns a `404 Not Found` status for a missing page. A pure SPA returns the `index.html` shell with `200 OK` for every URL and lets JavaScript decide to show a "not found" view. To Google [that looks like a valid page with thin content](https://developers.google.com/search/docs/crawling-indexing/javascript/fix-search-javascript), and indexed error pages drag down how it assesses your site. Fixes are in the [requirements section](#handle-404s-properly) below.

## Diagnose Your SPA

Three checks, in order of effort:

1. **View source.** Right-click your deployed page, "View Page Source". If your text isn't in the raw HTML, you depend on JS rendering and are invisible to AI crawlers and social bots
2. **URL Inspection in Search Console.** Run "Test Live URL" and compare the crawled HTML with the rendered HTML. Content that only appears in the rendered version depends on the render queue
3. **Disable JavaScript.** Turn off JS in DevTools and reload. A blank screen is what most non-Google crawlers see

## When SPA SEO Doesn't Matter

Not every app needs to rank. Skip most of this guide if:

- **Everything is behind a login.** A bank, a CRM, an admin panel. Crawlers can't log in and there's nothing public to index
- **The URL is the product.** The Figma editor (`figma.com/design/...`) doesn't need SEO; the marketing site (`figma.com`) does
- **It's an internal tool.** Dashboards and employee portals

For those pages, set `<meta name="robots" content="noindex">` and spend your effort on the public pages instead.

## Rendering Strategies

The fix is always the same idea: produce the HTML before the browser does.

### Server-Side Rendering (SSR)

The server renders each page per request and sends complete HTML. Best for dynamic content that changes per request or per user: e-commerce, news, search results. Rendering at the edge (Cloudflare Workers, [Vercel](https://vercel.com)) cuts the TTFB cost that SSR adds.

### Static Rendering: SSG, Prerendering, and ISR

Static Site Generation (SSG) renders every page once at build time and outputs static files. Best for blogs, docs, and marketing sites where content changes at deploy time. The constraint is build time: rendering 100,000 pages per deploy stops being fun.

Incremental Static Regeneration (ISR) is a hybrid of SSR and SSG: the framework generates pages statically, then re-renders them in the background when they go stale. Best for large catalogs where a full rebuild is too slow but content still changes.

### Dynamic Rendering: don't

Serving prerendered HTML to bots and the SPA to humans was a common workaround. Google now [calls it exactly that](https://developers.google.com/search/docs/crawling-indexing/javascript/dynamic-rendering): "a workaround and not a long-term solution", with SSR or static rendering recommended instead. It isn't treated as cloaking as long as bots and humans get similar content, but it doubles your infrastructure and fixes nothing for real users. See [dynamic rendering](/learn-seo/vue/spa/dynamic-rendering) for the full story.

## Technical Requirements

These apply regardless of rendering strategy.

### Use Real, Crawlable URLs

Search engines ignore everything after `#`, so hash-routed pages (`example.com/#/about`) all collapse into one URL. Use the History API so every page gets a clean, individually indexable path (`example.com/about`). Every router supports this; it's usually the default.

Crawlers also discover pages by following `<a href>` links. They don't click buttons or run `router.push()`:

```html
<!-- ✅ Crawler can follow this -->
<a href="/pricing">View Pricing</a>

<!-- ❌ Crawler ignores this -->
<button onclick="goToPricing()">View Pricing</button>
```

### Ship Metadata and Canonicals in the Initial Response

Each page needs its own `<title>`, meta description, and canonical URL, present in the HTML the server sends. Client-side updates arrive too late for bots that don't render. Verify with the [meta tag checker](/tools/meta-tag-checker).

SPAs also accumulate URL variants (`?ref=twitter`, `?utm_source=...`). A self-referencing canonical tag on every page tells Google which version to index. More in the [canonical URLs guide](/learn-seo/vue/controlling-crawlers/canonical-urls).

### Handle 404s properly

Google [recommends two options](https://developers.google.com/search/docs/crawling-indexing/javascript/fix-search-javascript) for missing pages in an SPA. Prefer redirecting to a URL your server answers with a real 404:

```js
window.location.href = '/not-found'
```

Or, if a redirect isn't possible, inject a noindex tag from your error state:

```js
if (pageNotFound) {
  const meta = document.createElement('meta')
  meta.name = 'robots'
  meta.content = 'noindex'
  document.head.appendChild(meta)
}
```

## Framework Notes

- **Vue / Nuxt**: Nuxt gives you SSR, SSG, and ISR per route by default, and the Nuxt SEO module bundles sitemap, robots, Schema.org, and OG image generation. Start with the [Nuxt SEO guide](/learn-seo/nuxt). Vue's experimental Vapor Mode (stable-track in Vue 3.6) will cut hydration cost further once it ships
- **React / Next.js**: [Next.js](https://nextjs.org) App Router with Server Components ships zero JS for static parts of a page. Use the built-in `Metadata` API for titles and Open Graph tags
- **Svelte / SvelteKit**: [SvelteKit](https://svelte.dev/docs/kit) server-renders by default; load data in `+page.server.js` so it's in the initial HTML
- **Angular**: SSR is first-class via [`@angular/ssr`](https://angular.dev/guide/ssr) (`ng add @angular/ssr`), which replaced the old Angular Universal package. Use the `Meta` and `Title` services so tags land in the server response

## Visibility in AI Answers

Once your content is server-rendered, two low-effort additions improve how AI engines parse it:

- **Schema.org JSON-LD** gives crawlers explicit structure. A `Product` node with `offers` and `price` parses more reliably than prices scattered through markup. Validate with the [schema validator](/tools/schema-validator)
- **Semantic HTML** (`<main>`, `<article>`, `<header>`) helps extractors separate content from navigation chrome

Question-shaped headings with direct answers underneath also correlate with AI citations, though nobody outside the AI labs knows the actual ranking factors. Go deeper in [optimizing content for AI search](/learn-seo/vue/launch-and-listen/ai-optimized-content).

## Tools

1. [Google Search Console](https://search.google.com/search-console): indexing status, Core Web Vitals field data, and the URL Inspection tool for crawled-vs-rendered comparison
2. [PageSpeed Insights](https://pagespeed.web.dev/): INP and LCP, the two vitals SPA architecture hurts most
3. [Screaming Frog](https://www.screamingfrog.co.uk/): desktop crawler with JS rendering (paid tier) to audit rendered content at scale
4. [Meta tag checker](/tools/meta-tag-checker): confirm titles and descriptions are server-rendered

## Checklist

<checklist id="spa-seo">

- Public pages render on the server (SSR) or at build time (SSG/ISR)
- Raw HTML contains your content ("View Source" test passes)
- URLs use the History API, no `#` routing
- All internal navigation uses `<a href>` links
- Titles, descriptions, and canonicals ship in the initial response
- Missing pages return a real 404 or inject noindex

</checklist>

For framework-specific walkthroughs: [Nuxt SEO guide](/learn-seo/nuxt) · [Vue SPA SEO](/learn-seo/vue/spa)
