SPA SEO: Fix the Core Problems in Single-Page Applications

SPA SEO explained: why client-side rendering breaks crawlers, and how SSR, SSG, prerendering, and correct routing make single-page apps rank.

Single-page applications are the dominant pattern for modern web apps: React, Vue, and Angular ship a near-empty HTML shell, then build the page in the browser with JavaScript. That architecture creates a fundamental conflict with how search engines and AI crawlers work. Most crawlers read the initial HTML response. If that response is empty, they index nothing.

This guide covers why SPAs fail SEO by default, what each solution path actually does, the specific mechanics you must get right regardless of which approach you pick, and how SPA architecture affects your visibility in AI-generated answers.

The core fix applies across Google ranking and AI citations: your content must be in the HTML that leaves the server. Everything else follows from that.

Why SPAs fail search engines by default

The problem is that the initial HTML document served by a client-side rendered (CSR) SPA contains almost no content. Googlebot, Bingbot, and AI crawlers like GPTBot and ClaudeBot receive something like this when they request your URL:

<!DOCTYPE html>
<html>
  <head>
    <title>My App</title>
  </head>
  <body>
    <div id="root"></div>
    <script src="/static/js/main.bundle.js"></script>
  </body>
</html>

Everything the user sees, including headings, body text, product descriptions, and metadata, only exists after the JavaScript bundle downloads, parses, and executes. According to Google’s documentation, pages with a 200 status are queued for rendering via headless Chromium, but that rendering can be delayed. Google’s own guidance states: “The page may stay on this queue for a few seconds, but it can take longer than that.” Bing and most AI crawlers often do not execute JavaScript at all, which means they see and index nothing.

This is the core problem explored in the JavaScript SEO guide. SPAs are the most common way sites fall into this trap, because the architecture that makes them fast and interactive for users is the exact architecture that makes them opaque to crawlers.

The solution paths: SSR, SSG, prerendering, and hydration

There are four main ways to make a SPA crawlable, each with genuine trade-offs. The right choice depends on whether your content is dynamic and whether you can invest in server infrastructure.

ApproachHow it worksBest forMain trade-off
Server-side rendering (SSR)Server renders full HTML on each requestDynamic, personalised contentNode.js server required; adds TTFB overhead
Static site generation (SSG)Full HTML built at deploy timeContent that doesn’t change per userRebuild required for content updates
Prerendering / dynamic renderingSeparate renderer serves HTML to bots, JS app to usersExisting SPAs during migrationTwo codebases; Google calls it a workaround, not a long-term fix
Hydration / islands architectureStatic HTML with selective JS hydrationHigh-performance content sitesFramework-specific; requires upfront architectural commitment

Server-side rendering

SSR moves rendering from the browser to the server. When a crawler or user requests a URL, the server runs the JavaScript framework and returns a fully rendered HTML document. The crawler reads real content immediately. The browser then “hydrates” the HTML, attaching event listeners so it behaves like a normal SPA.

Next.js, Nuxt, and Angular Universal all offer SSR. The React SEO guide covers Next.js specifically. The key cost is operational: SSR adds Time to First Byte (TTFB) latency compared to serving a static file, because the server is generating the HTML on demand. According to web.dev, “generating pages on the server takes time, which can increase your page’s TTFB.” Whether that trade-off is acceptable depends on how dynamic your content is.

Static site generation

SSG renders every page to HTML at build time. The result is a folder of plain HTML files served from a CDN with no server-side processing. Crawlers get full content instantly, Core Web Vitals are excellent, and hosting is cheap. Frameworks like Astro are purpose-built for this pattern.

The limitation is that all content must be known at build time. A marketing site, documentation, or blog is a perfect fit. An app that shows personalised dashboards or real-time data is not.

Prerendering and dynamic rendering

Prerendering uses a headless browser to execute the JavaScript and save the resulting HTML. When a crawler visits, it gets the pre-rendered HTML. Regular users still get the normal SPA.

This is a pragmatic solution for an existing SPA where SSR would require a full rewrite. However, Google is direct about its status: “dynamic rendering was a workaround and not a long-term solution for problems with JavaScript-generated content in search engines.” Google recommends SSR, SSG, or hydration instead. If you use dynamic rendering as a temporary bridge, the cardinal rule is that bots and users must see equivalent content. Google’s documentation is explicit that serving a page about cats to users and a page about dogs to crawlers is cloaking, but serving the same content in different rendering forms is not.

Hydration and islands architecture

Frameworks like Astro use an “islands” model: pages render as static HTML by default, with interactive components hydrated selectively. This gives you full crawlability plus the interactive components your app needs, without the TTFB overhead of full SSR. It requires committing to the framework from the start, but it avoids the latency problem entirely.

Four mechanics SPAs routinely get wrong

Whichever rendering approach you choose, these four areas cause most real-world SPA SEO failures.

1. Client-side routing with real URLs

SPAs manage navigation internally. When a user clicks from one view to another, the JavaScript updates the display without a full page reload. The problem is how the URL changes.

The old pattern was hash-based routing: https://example.com/#/products. Crawlers treat everything after # as a fragment identifier, not a page address. They only index https://example.com/, and every “page” in your app is invisible. Google’s documentation explicitly states: “Google can only discover your links if they are <a> HTML elements with an href attribute” and advises using the History API instead of hash-based routing.

The History API lets JavaScript update the full URL path without reloading the page:

window.history.pushState({}, '', '/products/running-shoes');

React Router, Vue Router, and Angular Router all use the History API by default. The one thing to verify is that your server returns the app shell for any path, not just /. Otherwise, direct URL visits and crawler requests to non-root paths return 404s. Configure your server or hosting platform to serve index.html for all routes.

2. Unique title and meta description per route

A CSR SPA with a static <title>My App</title> in the HTML shell means every page of your site has the same title in Google’s index. That collapses your entire site into a single entity as far as search is concerned.

Every route needs its own <title> and <meta name="description">. React’s standard solution is a library like react-helmet-async:

import { Helmet } from 'react-helmet-async';

function ProductPage({ product }) {
  return (
    <>
      <Helmet>
        <title>{product.name} | My Store</title>
        <meta name="description" content={product.shortDescription} />
        <link rel="canonical" href={`https://example.com/products/${product.slug}`} />
      </Helmet>
      <h1>{product.name}</h1>
    </>
  );
}

This works for users and for crawlers that execute JavaScript. For crawlers that do not run JavaScript, the metadata update never happens. This is another reason SSR or SSG is the more robust solution: titles and descriptions are baked into the HTML response, not injected by client-side code. Google’s documentation recommends setting canonical URLs in HTML rather than via JavaScript specifically for this reason.

In Next.js App Router, the equivalent is the Metadata API in each route’s page.tsx. In Vue, it is vue-meta or Nuxt’s built-in useHead composable. In Angular SEO, the Meta and Title services from @angular/platform-browser handle it, and Angular Universal makes the update happen server-side.

3. Correct HTTP status codes

A well-optimised SPA still commonly fails at HTTP status codes. The issue is that the server serves a 200 response for every URL because it is serving the same index.html shell. When the JavaScript app decides a route does not exist, it renders a 404 component, but the HTTP response was already 200.

Google calls these “soft 404s” and treats them as low-quality pages. Products that are discontinued, articles that were deleted, and users that no longer exist all return 200 with content that says “not found.” Google indexes them, users land on error states, and your crawl budget gets wasted. For CSR SPAs, Google acknowledges that returning meaningful HTTP status codes “can be impossible or impractical” without SSR, and recommends two mitigations: redirect the error state to a URL your server returns a real 404 for, or add <meta name="robots" content="noindex"> to the error page via JavaScript.

Fixing this properly requires SSR so the server can return a real 404 response code before sending any HTML. Without SSR, the only partial mitigation is ensuring soft-404 pages do not appear in your sitemap and carry a noindex tag.

4. A sitemap listing every route

Crawlers discover pages by following links and reading sitemaps. SPAs often have no meaningful link structure in their initial HTML because all the links are rendered by JavaScript after page load. The crawler cannot discover routes by following links from the HTML response.

A sitemap at https://example.com/sitemap.xml listing every canonical URL is the primary way to ensure Googlebot knows every page exists. For a static site, generate this at build time. For a server-rendered app, generate it dynamically from your database or content source.

Every URL in the sitemap should return a real 200, have unique content, and have a canonical tag pointing to itself. Do not include paginated duplicates, filter variations, or soft-404 pages.

How SPA architecture affects AI citations

AI search engines, including ChatGPT’s web browsing, Perplexity, and Google’s AI Overviews, draw on the same crawled content that feeds traditional search. An SPA that serves empty HTML to crawlers is invisible to AI engines and will not be cited regardless of how strong the underlying content is.

The practical difference goes beyond basic indexation. AI citation systems tend to favour content that is present in the raw HTML response, because it is reliably extractable and attributable. An SSR or SSG page that delivers its full text in the initial HTTP response is straightforwardly parseable by any crawler. A CSR SPA that requires JavaScript execution introduces uncertainty: the crawler may render it, may not, or may render it hours or days later, at which point the content snapshot used for AI training data may not include it.

Google’s own guidance notes that server-side or pre-rendering “makes your website faster for users and crawlers, and not all bots can run JavaScript.” That observation covers AI crawlers directly. GPTBot (OpenAI), ClaudeBot (Anthropic), and PerplexityBot all follow standard HTTP semantics. If your content is not in the initial HTML response, there is no guarantee any of them will ever see it.

Once your rendering approach is solid, the next layer is making your content actually citation-worthy: structured, authoritative, clearly attributed. The AI SEO guide covers what is needed beyond crawlability. Tools like Fokal let you query AI engines directly and confirm whether your brand and content are appearing in AI-generated answers, which is the only reliable way to know whether your rendering approach is working in practice.

What the platform SEO guide covers

SPA SEO sits within a broader set of framework-specific decisions. The platform SEO hub covers the full range: from static-first frameworks like Astro to server-rendered stacks like Next.js to no-code builders. The rendering decision you make for your SPA has direct implications for performance, maintenance, and the AI visibility question every site owner now has to answer.

SPA SEO checklist

  • Rendering: SSR, SSG, or prerendering in place (no empty HTML shell served to crawlers)
  • Routing: History API used, not hash fragments
  • Server configured to return app shell for all paths (no 404 on direct URL visits)
  • Unique <title> and <meta name="description"> set for every route
  • Canonical tag on every route pointing to its own URL
  • Real HTTP 404 returned for missing pages (not soft 200)
  • Sitemap generated listing every canonical URL
  • Sitemap submitted to Google Search Console
  • JavaScript-disabled fetch confirms content is in the HTML response
  • AI engine citations checked to confirm content is being picked up

Your profile goes live in minutes.