---
title: "SPA SEO: How to Get a Single-Page App Indexed (2026)"
url: https://encited.com/blog/spa-seo
description: "Why single-page apps built with React, Vue or Angular get indexed late or not at all, what Googlebot and AI crawlers receive, and four rendering fixes."
lang: en
---

Image: SPA SEO: How to Get a Single-Page Application Crawled, Indexed and Cited (https://encited.com/blog/thumbnails/spa-seo.webp)

# SPA SEO: How to Get a Single-Page Application Crawled, Indexed and Cited

Oct 29, 2026 October 29, 2026 · by Aki from Encited

Why single-page apps built with React, Vue or Angular get indexed late or not at all, what Googlebot and AI crawlers receive, and four rendering fixes.

**TL;DR.** A single-page app ships one HTML file and builds every page in the browser with JavaScript. Googlebot can run that JavaScript, but it does so in a second pass that can lag the first crawl by hours, and most AI crawlers never run it at all. The fixes, in order of effort: serve prerendered HTML to crawlers, use static generation for pages that rarely change, or move to server-side rendering. Before any of that, fix the five things that break SPAs regardless of rendering: hash routing, click-handler links, meta tags set after load, soft 404s and content behind user actions.

If you built your site with React, Vue, Angular, or an AI builder that outputs one of them, you have a single-page application. That is fine for users. It is a problem for anything that reads your site without a browser. Everything below assumes you already have a working app and would rather fix it than rebuild it.

## What a single-page application sends to a crawler

A traditional site returns a different HTML document for each URL. A SPA returns the same document for every URL. That document is a near-empty shell: a `<div id="root">` or `<div id="app">`, a script tag, and little else. The JavaScript bundle then fetches data, builds the page, and updates the address bar as you move around.

Open one of your pages and view source (not the Elements panel in DevTools, which shows the page after JavaScript has run). If the body is a few lines long and your content is nowhere in it, that is what every crawler receives on its first request.

Whether that matters depends entirely on whether the crawler runs the script.

## How Google handles a SPA

Google crawls and indexes JavaScript sites in two passes.

In the first pass, Googlebot fetches the raw HTML. For a SPA, that is the shell. Google extracts what it can from it: the title if one is hard-coded, any links in the markup, the canonical if it is present. Then the URL goes into a render queue.

In the second pass, Google's Web Rendering Service loads the page in a headless Chromium, runs the JavaScript, waits for the page to settle, and reads the rendered DOM. Only then does Google see your content, your real title, and the links your router generated.

Two things follow from this.

The gap between the passes is not fixed. Vercel and MERJ measured it (https://vercel.com/blog/how-google-handles-javascript-throughout-the-indexing-process) on nextjs.org in April 2024 and published the results on July 31, 2024: the median wait from crawl to finished render was 10 seconds, and the slowest 1% waited about 18 hours. URLs with query strings waited longer. New product pages, news, and anything time-sensitive is exposed during that gap.

Rendering costs Google compute, and rendering is where things go wrong. If your bundle throws an error, times out, depends on a blocked resource, or waits for a user action before loading content, the rendered DOM is incomplete and Google indexes what it got.

The same study covered more than 100,000 Googlebot fetches, and every HTML page that returned a valid, indexable response got a full render. So Google can read a SPA. The question is how reliably and how quickly, and the answer varies by site.

## Why AI crawlers are the bigger problem

Vercel and MERJ published a second set of crawler data on December 17, 2024, this time on the AI crawlers (https://vercel.com/blog/the-rise-of-the-ai-crawler). None of the major ones rendered JavaScript. GPTBot, OAI-SearchBot, ChatGPT-User, ClaudeBot and PerplexityBot fetched the raw HTML and moved on. ChatGPT's and Claude's crawlers downloaded JavaScript files in a share of their requests, and neither executed them.

Google renders. The AI crawlers do not. If your content is only in the DOM after JavaScript runs, these crawlers see an empty page. Your site can be fully indexed in Google and invisible to every AI assistant at the same time.

There is a second class of AI request that matters more. When someone asks ChatGPT or Perplexity a question and the assistant goes to the web to answer it, it fetches pages live, in the moment, with no render queue and no second pass. If the fetch comes back empty, you are not in the answer.

We wrote up what each crawler does in detail in Do AI crawlers render JavaScript? (https://encited.com/blog/do-ai-crawlers-render-javascript). The short version: assume they do not.

## Five things that break SPA SEO regardless of rendering

Rendering fixes the empty shell. It does not fix these. Check them first, because they are cheaper to fix and they break indexing even on a server-rendered site.

**1. Hash routing.** Older SPAs use URLs like `example.com/#/pricing`. Everything after `#` is a fragment, and crawlers treat `example.com/#/pricing` and `example.com/#/about` as the same URL. Switch to history-mode routing so each page has a real path. React Router, Vue Router and the Angular router all support this; the change is one setting plus a server rule that returns your `index.html` for every path.

**2. Links that are not links.** A `<div onClick={navigate('/pricing')}>` is invisible to a crawler. Googlebot follows `<a href="...">` and nothing else. Your router's link component renders a real anchor, so use it. Buttons that navigate are the usual culprit.

**3. Titles and meta tags set after load.** If your `<title>`, meta description, canonical and Open Graph tags are set by a library like React Helmet after the page mounts, they exist only in the rendered DOM. Google will usually pick them up in the second pass. AI crawlers and link previews (Slack, LinkedIn, iMessage) will not. Either render them into the HTML or prerender the page.

**4. Soft 404s.** A SPA's server returns `index.html` with a 200 status for every path, including ones that do not exist. The app then shows a "Not found" screen. Google calls this a soft 404 and may index the junk URL, or may decide your site returns 200 for everything and trust it less. Return a real 404 status for unknown routes, either from the server or from your prerendering layer.

**5. Content behind user actions.** Tabs, accordions, "load more" buttons and infinite scroll load content on click or on scroll. Googlebot does not click and does not scroll. It renders with a tall viewport, which catches some lazy-loaded content and misses the rest. If a product's description is in a tab that fetches on open, it is not in the index. Put the content in the DOM at load and hide it with CSS if you must.

Fix these five and most SPAs move from "not indexed" to "indexed late." The rendering choice decides how late.

## Your rendering options

There are four ways to get real HTML to a crawler. Each one trades effort for coverage.

| Option | What it does | Fixes Google | Fixes AI crawlers | Effort |
| --- | --- | --- | --- | --- |
| Client-side rendering (what you have) | Browser builds the page | Partly, with delay | No | None |
| Prerendering | A service renders the page once and serves cached HTML to crawlers | Yes | Yes | Low, no code changes |
| Static site generation | Pages built to HTML at deploy time | Yes | Yes | Medium, needs a framework that supports it |
| Server-side rendering | Server builds the HTML on every request | Yes | Yes | High, usually a framework migration |

**Prerendering** sits in front of your site. When a request comes from a crawler user agent, it serves a rendered HTML snapshot. When a request comes from a person, it passes through to your app untouched. You keep your SPA as it is. This is what Google documented as dynamic rendering in 2018, and in 2022 it relabeled the page "Dynamic rendering as a workaround." That label gets quoted a lot. In the same period Google's John Mueller said there is no rush to move off it, and the AI crawlers that arrived since cannot read a SPA any other way. We cover the history and the cloaking question in Dynamic rendering for SEO (https://encited.com/blog/dynamic-rendering-seo).

**Static site generation** builds every page to HTML when you deploy. It is the best option for pages that change rarely: marketing pages, docs, blog posts. It is a poor fit for large catalogs, user-specific pages, or anything that changes between deploys. Next.js, Nuxt, Astro and SvelteKit all support it. Vite SPAs do not without a plugin.

**Server-side rendering** builds the HTML on the server for every request, then the browser takes over. Full coverage, fresh content, and the heaviest lift. For most teams it means moving from a plain React, Vue or Angular app to Next.js, Nuxt or Angular Universal. If you are planning that migration anyway, do it. If you are not, it is a large project to take on for crawlers alone. We compared the two directly in Dynamic rendering vs server-side rendering (https://encited.com/blog/dynamic-rendering-vs-server-side-rendering).

For an existing SPA: prerender now, because it takes an afternoon and covers Google and AI crawlers. Plan SSG or SSR for the next rebuild if you have one coming.

## Prerendering a SPA without touching the code

A prerendering layer needs three things: a way to tell crawlers from people, a renderer that produces the snapshot, and a cache so the renderer is not run on every bot request.

Detection is by user agent. The list has to cover Googlebot, Bingbot, the social preview bots (facebookexternalhit, Twitterbot, LinkedInBot, Slackbot), and the AI crawlers (GPTBot, OAI-SearchBot, ChatGPT-User, ClaudeBot, PerplexityBot and others). The list changes. Whoever maintains your layer has to maintain the list.

The renderer loads the page in a headless browser, waits until the app has finished, and captures the DOM. "Finished" is the hard part. Most apps can be detected by waiting for network idle. Some need a signal from your code that the data has loaded, such as setting `window.prerenderReady = true` once the last fetch resolves.

The cache decides freshness. A snapshot from last month is worse than no snapshot for a product page that changed yesterday. Set refresh rules by path: short for pages that change, long for pages that do not, and a manual re-render after a deploy.

You can build this yourself with Puppeteer or Rendertron on a server you run. We wrote up what that involves and where it tends to fall over in Puppeteer vs a managed dynamic rendering service (https://encited.com/blog/puppeteer-vs-managed-dynamic-rendering). Or you can put a service in front of your DNS and skip the server.

## Framework notes

The problems above are the same in every framework. The fixes differ in where the settings live.

**React (Vite or Create React App).** Use `BrowserRouter`. `HashRouter` produces the fragment URLs from problem one. Use `<Link>` for navigation. React Helmet or `react-helmet-async` set tags after mount, which is fine for Google and useless for everything else. Vite has no built-in SSG; `vite-plugin-ssr` (now Vike) or a move to Next.js are the usual routes. Full detail in React SEO (https://encited.com/blog/react-seo).

**Vue.** Use `createWebHistory()` in Vue Router. `createWebHashHistory()` gives you the hash URLs from problem one. `@unhead/vue` handles meta tags. Nuxt gives you SSR and SSG with the same components. Detail in Vue SEO (https://encited.com/blog/vue-seo).

**Angular.** The default router already uses path URLs. Meta tags go through the `Title` and `Meta` services. Angular Universal (now part of Angular SSR) handles server rendering, and the migration is more involved than the Vue or React equivalents. Detail in Angular SEO (https://encited.com/blog/angular-seo).

**AI website builders** (Lovable, Bolt, v0, Base44, Replit and the rest) mostly output a Vite React SPA. Everything in the React section applies, and you usually cannot change the rendering mode from inside the builder. Prerendering is the practical route. The setup steps are in our docs (https://encited.com/docs).

## Large SPAs: crawl budget and the render queue

Everything above gets worse with scale. A marketing site with 40 pages can live with a slow render queue. A catalog with 40,000 product pages cannot.

Rendering is the expensive step for Google, and Google rations it. On a large site, the render queue is where your long tail of pages waits. Pages that are crawled but sit unrendered for days show up in Search Console as "Crawled, currently not indexed" or "Discovered, currently not indexed." We have guides on both: Crawled, currently not indexed (https://encited.com/blog/crawled-currently-not-indexed) and Discovered, currently not indexed (https://encited.com/blog/discovered-currently-not-indexed). On a SPA, the usual cause is that Google fetched the shell, queued the render, and has not got to it.

Three things reduce the pressure.

**Serve HTML to the crawler.** If the first fetch already contains the content, there is nothing to queue. Prerendering, SSG and SSR all do this. This is the single biggest crawl budget win available to a large SPA.

**Cut the URLs that do not deserve rendering.** Filter combinations, sort orders, pagination parameters, session IDs and internal search results all generate URLs. Each one Google crawls and queues is budget taken from a product page. Block them in robots.txt, canonicalize them to the parent, or set them to `noindex` so Google stops returning. Our faceted navigation SEO (https://encited.com/blog/faceted-navigation-seo) guide covers the decision rules.

**Give Google a clean list.** A sitemap with only canonical, indexable URLs, refreshed on deploy, tells Google what to spend budget on. Keep error URLs, redirected URLs and `noindex` URLs out of it; each one is a wasted fetch and a signal that the sitemap cannot be trusted.

You can watch the effect in Search Console's crawl stats report. After prerendering, average response time for Googlebot usually drops, because it is receiving cached HTML, and pages per day usually rises.

## The per-page checklist

Once rendering is handled, each page still needs the basics in the HTML a crawler receives. Run through this on the rendered output. Your source files will show you what you wrote; the crawler only sees what was served.

- One `<title>` per page, unique, under 60 characters
- One meta description per page, unique
- One `<h1>`, matching the page's topic
- A self-referencing canonical, as an absolute URL
- Open Graph and Twitter card tags with absolute image URLs
- `hreflang` tags if you serve multiple languages
- Structured data as JSON-LD in the HTML (Product, Article, FAQPage, BreadcrumbList as appropriate)
- Internal links as real anchors with descriptive text
- A real 404 status for unknown routes
- A sitemap listing every indexable URL, submitted in Search Console and Bing Webmaster Tools
- No `noindex` left over from staging

Most SPAs fail three or four of these at launch. The canonical and the absolute image URLs are the ones we see most often. For a whole site, a site audit (https://encited.com/site-audit) crawls every route and flags the misses in one pass.

## How to test what a crawler actually sees

Do not test in your browser. Your browser runs JavaScript.

**Fetch the raw HTML.** `curl -A "GPTBot" https://yoursite.com/some-page` shows what an AI crawler gets. If the body is a shell, that is the problem. Our crawler simulator (https://encited.com/free-tools/crawler-simulator) does the same for two dozen user agents without the terminal.

**Fetch as Googlebot.** The Googlebot simulator (https://encited.com/free-tools/crawler-simulator/googlebot-simulator) shows the raw response. For the rendered result, use URL Inspection in Google Search Console, click "Test live URL," then "View tested page." The HTML tab is what Google indexed. The Screenshot tab shows whether it rendered correctly.

**Compare raw and rendered.** The difference between the two is everything you are trusting Google's render queue to find and everything AI crawlers will never see.

**Check status codes.** Request a URL that does not exist. If you get 200, you have soft 404s.

**Check your logs.** After you change anything, confirm crawlers are receiving the new version. Search Console's crawl stats show Googlebot. Your prerendering layer's logs should show every bot, what it received and whether it came from cache. If you cannot see that, you are guessing.

## FAQ

**Can Google index a single-page application?** Yes. Googlebot renders JavaScript in a second pass after the initial crawl. Indexing is slower than for plain HTML and can fail when the render errors, times out or depends on user interaction.

**Are SPAs bad for SEO?** A SPA with history routing, real links, server-set meta tags and real 404s indexes fine in Google, with some delay. The same SPA is invisible to AI crawlers unless it is prerendered or server-rendered.

**Do I need to migrate to Next.js or Nuxt?** Only if you want server-side rendering specifically. Prerendering gives crawlers full HTML with no code changes. SSG works if your framework supports it and your pages change rarely.

**Is prerendering cloaking?** Google's own documentation says dynamic rendering is not cloaking as long as the prerendered content matches what users see. Serve the same content, just rendered, and you are fine.

**How do I know if AI crawlers can read my site?** Fetch a page with `curl -A "GPTBot"` or run it through a crawler simulator. If your content is in the response, they can read it. If the response is a shell, they cannot.

**Does Bing render JavaScript?** Yes, with limits. Bing's webmaster guidelines (https://www.bing.com/webmasters/help/webmaster-guidelines-30fba23a) list "Hiding critical content behind client-side rendering" as something to avoid, and say content that cannot be reliably rendered may not be indexed or selected for grounding. The same index feeds Copilot, so a SPA that Bing struggles with is missing from Copilot answers too. Prerendering removes the question.

If your site is a SPA and you are not sure what crawlers receive, run one page through the crawler simulator (https://encited.com/free-tools/crawler-simulator). It takes ten seconds and tells you which of the problems above you have.

Image: Google (https://encited.com/crawler-logos/google-search.svg)

Image: Gemini (https://encited.com/crawler-logos/gemini.png)

Image: ChatGPT (https://encited.com/crawler-logos/chatgpt.svg)

Image: Claude (https://encited.com/crawler-logos/claude.svg)

Image: Grok (https://encited.com/crawler-logos/xai.svg)

Image: Meta (https://encited.com/crawler-logos/meta.svg)

Image: Perplexity (https://encited.com/crawler-logos/perplexity.png)

## Get discovered anywhere search happens

Readable, citable, outranking pages.

Talk to sales: https://encited.com/contact-sales

Image: Avatar (https://replymaven.com/api/uploads/Su6Q0nK63iZW0ZFJBRNWpmRuppVDLuab/98df5262-f992-45fa-a750-00a6c919baf9.png)

How can we help?

Get instant answers to your questions or leave a message for an engineer will reach out

Ask AI about Encited

See our docs (https://lovablehtml.com/docs)

Contact support

Leave a message

We'll get back to you soon

Ask AI about Encited

Product questions, troubleshooting and

Thinking

Image: Preview

Drop an image to attach

Powered by ReplyMaven (https://replymaven.com/)

## Structured data

```json
[
  {
    "@context": "https://schema.org",
    "@type": "BreadcrumbList",
    "itemListElement": [
      {
        "@type": "ListItem",
        "position": 1,
        "name": "Home",
        "item": "https://encited.com/"
      },
      {
        "@type": "ListItem",
        "position": 2,
        "name": "Blog",
        "item": "https://encited.com/blog"
      },
      {
        "@type": "ListItem",
        "position": 3,
        "name": "SPA SEO: How to Get a Single-Page Application Crawled, Indexed and Cited",
        "item": "https://encited.com/blog/spa-seo"
      }
    ]
  },
  {
    "@context": "https://schema.org",
    "@type": "Article",
    "headline": "SPA SEO: How to Get a Single-Page Application Crawled, Indexed and Cited",
    "description": "Why single-page apps built with React, Vue or Angular get indexed late or not at all, what Googlebot and AI crawlers receive, and four rendering fixes.",
    "image": "https://encited.com/blog/thumbnails/spa-seo.webp",
    "datePublished": "2026-10-29",
    "dateModified": "2026-10-29",
    "author": {
      "@type": "Person",
      "name": "Aki from Encited"
    },
    "publisher": {
      "@type": "Organization",
      "name": "Encited.com",
      "alternateName": "LovableHTML.com",
      "logo": {
        "@type": "ImageObject",
        "url": "https://encited.com/brand/logo.svg"
      }
    },
    "mainEntityOfPage": {
      "@type": "WebPage",
      "@id": "https://encited.com/blog/spa-seo"
    }
  }
]
```