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, and the specific things you must get right regardless of which approach you pick.
Why SPAs are hard for search engines
The core 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. Google does eventually run JavaScript, but it processes JS-rendered pages in a second crawl wave that can be delayed by days or weeks. Bing and most AI crawlers often do not execute JavaScript at all, which means they see and index nothing.
This is the issue described in depth in our 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 same architecture that makes them opaque to crawlers.
The solution paths: a comparison
There are four main ways to solve SPA SEO, each with genuine trade-offs.
| Approach | How it works | Best for | Main trade-off |
|---|---|---|---|
| Server-side rendering (SSR) | Server renders full HTML on each request | Dynamic, personalised content | Server infrastructure required; higher latency |
| Static site generation (SSG) | Full HTML built at deploy time | Content that doesn’t change per user | Rebuild required for content updates |
| Prerendering / dynamic rendering | Separate renderer serves HTML to bots, JS app to users | Existing SPAs, gradual migration | Maintains two codebases; can be flagged as cloaking if misused |
| Hydration / islands architecture | Static HTML with selective JS hydration | High-performance content sites | Framework-specific; requires architectural commitment |
Server-side rendering (SSR)
SSR moves rendering from the browser to the server. When a crawler or user requests a URL, the server runs the same 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 in detail. The main cost is operational: you need a Node.js server running at all times, and rendering adds latency compared to serving a static file.
Static site generation (SSG)
SSG renders every page to HTML at build time. The result is a folder of plain HTML files that can be served from a CDN with no server-side processing. Crawlers get full content instantly, Core Web Vitals are excellent, and hosting is cheap.
The limitation is that 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 (Puppeteer, Rendertron, or a commercial service) 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 adoption would require a full rewrite. Google’s documentation acknowledges this pattern, though they note it should only be used as a temporary measure and that the crawler and user should see the same content. Significant discrepancies between what bots and users see can be treated as cloaking.
Hybrid 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. It requires building on the right framework from the start, but it avoids the SSR latency problem entirely.
SPA-specific SEO requirements
Whichever rendering approach you choose, there are four mechanics that SPAs routinely get wrong.
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.
The correct approach is the History API, which lets JavaScript update the full URL path without reloading the page:
// Push a new URL into browser history without reloading
window.history.pushState({}, '', '/products/running-shoes');
React Router, Vue Router, and Angular Router all use the History API by default in their standard modes. The only thing to verify is that your server is configured to return the app shell for any path (not just /), otherwise direct URL visits 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 canonical 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>
{/* rest of component */}
</>
);
}
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 why SSR or SSG is the more robust long-term solution: titles and descriptions are baked into the HTML response, not injected by client-side code.
In Next.js App Router, the equivalent is the Metadata API in each route’s page.tsx. In Vue, it is vue-meta or the built-in useHead composable in Nuxt. 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’s serving the same index.html shell. When the JavaScript app decides a route doesn’t 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.
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 don’t appear in your sitemap and have a <meta name="robots" content="noindex"> tag.
4. A sitemap listing every route
Crawlers discover pages by following links and by reading sitemaps. SPAs often have no meaningful link structure in their initial HTML (all the links are rendered by JavaScript after page load), which means the crawler can’t discover routes by following links.
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. Don’t include paginated duplicates, filter variations, or soft-404 pages.
How SPA SEO affects AI citations
AI search engines, including ChatGPT’s web browsing, Perplexity, and Google’s AI Overviews, rely on the same crawled content that feeds traditional search. If a crawler indexing your content doesn’t execute JavaScript, your SPA’s content simply doesn’t exist in the data those AI engines draw from.
This is a meaningful practical difference from traditional SEO. Google does eventually render JavaScript for traditional ranking purposes, but AI citation tends to favour content that is available in the raw HTML response, because it’s reliably extractable. Server-rendered or statically generated content is consistently present in the initial response, which makes it easier to extract, attribute, and cite.
If a brand wants to appear in AI-generated answers for their category, the prerequisite is that their content is actually readable. An SPA that serves empty HTML to crawlers is invisible to AI engines and will not be cited regardless of how good the underlying content is. The AI SEO guide covers what else is needed once your content is reliably crawlable.
Tools like Fokal can help here by querying AI engines directly and showing you whether your brand and content are actually appearing in AI answers. That feedback loop tells you whether your rendering approach is working in practice, not just in theory.
What to do next
If you’re working on an existing SPA, the quickest diagnostic is to fetch your page URLs with JavaScript disabled (in Chrome DevTools, Network tab, set to “Disable cache” then check “Disable JavaScript” in Settings) and read what’s actually in the HTML. If you see your content, crawlers can too. If you see an empty div, they can’t.
For new builds, the decision tree is straightforward: if the content is mostly static or changes at deploy time, use SSG. If it’s dynamic and personalised, use SSR. If you’re maintaining an existing SPA and can’t rewrite the rendering layer yet, add prerendering as a bridge and prioritise getting the History API routing, per-route metadata, and a complete sitemap in place first.
The full picture of JavaScript-framework SEO is in the platform SEO guide, which covers the decision points across React, Angular, and static-first frameworks.
SPA SEO checklist
- Rendering: SSR, SSG, or prerendering is in place (no empty HTML shell served to crawlers)
- Routing: History API used, not hash fragments
- Server configured to serve 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