JavaScript & Framework SEO

SPA SEO: How to Make a Single-Page App Rank in 2026

Published: February 12, 2026 12 min read

SPA SEO is the work of making a single-page application (a JavaScript app that ships one HTML shell and builds every view in the browser) crawlable, indexable, and rankable. Out of the box, most SPAs fail at this in predictable ways: crawlers receive an empty <div>, every view shares one URL, and error pages return 200. Each of those failures has a documented fix, and this guide covers them in order of impact.

One scope note before anything else. This article is about single-page applications built with React, Vue, Angular, or Svelte. It is not about marketing for day spas or med spas (same three letters, different industry, and even Google’s AI Overview mixes the two on this query), and it is not about one-page brochure websites, which have their own, much simpler set of problems.

What Is SPA SEO? (Single-Page Applications, Not Day Spas)

SPA SEO covers everything required for a single-page application’s content to end up in Google’s index and in AI-generated answers: HTML that crawlers can read without executing scripts, a real URL for every view, correct status codes, per-route metadata, and responsive interactions. It exists as a discipline because SPAs invert the model search engines were built on. A traditional site returns finished HTML for every URL. A SPA returns one nearly empty HTML file plus a JavaScript bundle that assembles each view client-side.

That leads straight to the question half of r/webdev keeps arguing about: are single-page applications bad for SEO?

Not inherently. Google executes JavaScript and can crawl and index a well-built SPA. The problem is the default client-side-rendered setup, which fails in specific ways:

Every item on that list is fixable without abandoning your framework. Some fixes are configuration, some are architecture. Let’s start with what crawlers actually see, because that’s where the diagnosis begins.

Why SPAs Struggle in Search: What Crawlers Actually See

Open view-source on a client-side-rendered app and you’ll find something like <div id="root"></div>, a few script tags, and not a word of your product copy. Everything a visitor sees is constructed after the JavaScript downloads, parses, and runs.

Google can handle this, with caveats. According to Google Search Central, Google processes JavaScript apps in three phases: crawling, rendering, and indexing. After crawling, a page goes into a rendering queue, where Google says it “may stay for a few seconds, but it can take longer than that.” Rendering runs on an evergreen version of Chromium, so modern JS features aren’t the issue. The queue is.

How much longer than a few seconds? A Vercel and MERJ study of over 100,000 Googlebot fetches on nextjs.org found Google achieved full-page renders on 100% of HTML pages, including JavaScript-heavy ones. The median rendering-queue time was 10 seconds and the 25th percentile rendered within 4 seconds. But the 90th percentile took around 3 hours, and the 99th around 18 hours. So Google will see your content, eventually. Rendering-dependent indexing is slower, and every added step is a new failure mode: a JS error, a blocked resource, a timed-out API call, and that render produces a blank page.

Google is also no longer the only reader that matters. Vercel’s network data shows that the major AI crawlers (OpenAI’s GPTBot, Anthropic’s ClaudeBot, PerplexityBot, Meta-ExternalAgent, Bytespider, CCBot) fetch JavaScript files but do not execute them. Only Gemini (which rides Googlebot’s infrastructure) and AppleBot render JS. These crawlers generated nearly 1.3 billion fetches per month in that dataset, about 28% of Googlebot’s volume, with GPTBot alone at 569 million requests monthly. We break down what that means per bot in our test of which AI crawlers execute JavaScript.

Routing and URLs: Use the History API, Never Hash Fragments

Every view you want to rank needs its own crawlable URL. Google’s guidance here is unusually blunt: use the History API to implement routing between views, and don’t use fragments to load different content, because Googlebot cannot reliably parse and extract fragment URLs. That’s straight from the JavaScript SEO basics documentation, and it makes hash routing a hard disqualifier for any public page.

In React Router terms, this is the choice between the two router factories:

// Hash routing: every view hides behind #/..., which Googlebot ignores.
// https://app.example.com/#/pricing is just https://app.example.com/ to a crawler.
const router = createHashRouter(routes); // do not use for public pages

// History API routing: every view gets a real, crawlable URL.
// https://app.example.com/pricing is a distinct page Google can index.
const router = createBrowserRouter(routes);

Vue Router (createWebHistory vs createWebHashHistory) and Angular (PathLocationStrategy vs HashLocationStrategy) offer the same fork. Pick the History API side every time; the only cost is configuring your server to return the app shell for deep URLs.

Table mapping hash routing versus History API routing to the router factory in React Router, Vue Router, and Angular.
Every major router offers both modes; pick the History API side and each view gets a crawlable URL.

Three more routing rules that SPAs routinely break:

  1. Use real <a href> links. Googlebot discovers URLs from href attributes. A div with an onclick handler that calls router.push() navigates fine for users and is invisible to crawl discovery. Your router’s link component renders a real anchor and intercepts the click. Use it everywhere.
  2. Put every route in your XML sitemap. Client-side routes don’t exist anywhere a crawler can enumerate them. The sitemap is your route manifest for search engines; generate it from the same route table your router uses so they never drift.
  3. One canonical per view. Each route needs a self-referencing canonical tag that updates on navigation. If every view reports the homepage as canonical (a common default), you’ve told Google to index one page out of your entire app.

Rendering Fixes, Ranked: SSR, Prerendering, and Why Dynamic Rendering Is Dead

Clean URLs get your views crawled. Rendering determines what crawlers find there. This is a decision framework, not a tool list, because the right answer depends on the page type, not the framework.

Route typeExampleRendering fix
Stable marketing pageshome, features, pricing, blogStatic prerendering / SSG at build time
Frequently changing public contentlistings, inventory, user-generated contentServer-side rendering
App behind logindashboard, settings, editorNone needed; client-side is fine here
Legacy SPA you can’t refactor nowany public routePrerendering middleware, as a bridge

Static generation first. If a page’s content changes only when you deploy (true for almost every marketing page), prerender it to HTML at build time. It’s the cheapest option, the fastest to serve, and the most reliable: there is no render step to fail, and every crawler, JS-executing or not, gets the full content from the first byte.

SSR for content that changes between builds. Next.js, Nuxt, and Angular’s built-in SSR render each request on the server and hydrate in the browser. You get crawler-complete HTML plus live data, at the cost of running and scaling a server. If you’re going this route in React, our Next.js SEO guide covers the framework-specific details.

Islands and partial hydration sit between the two: static HTML with interactivity attached only where needed. This site runs on that model.

Prerendering middleware (Prerender.io and similar) serves bots a headless-browser snapshot while users get the live app. It works, and for a legacy codebase it’s often the only fix that ships this quarter. Treat it as a bridge: snapshots drift from the live app, and you’re maintaining two versions of the truth. The middleware route and other no-rewrite options are covered in our guide to React SEO without SSR.

Dynamic rendering, serving bots one thing and users another based on user-agent, is dead. Google’s own dynamic rendering documentation now states it “was a workaround and not a long-term solution for problems with JavaScript-generated content” and points to server-side rendering, static rendering, or hydration instead. Don’t build anything new on it.

Metadata, Status Codes, and the Soft-404 Trap

Rendering gets your content seen; metadata tells search engines what each view is. A SPA serves one physical HTML file, which means one title, one description, and one set of OG tags unless you actively update them on every route change. Every modern framework has a mechanism for this (React 19’s native metadata support, useHead in Nuxt, Angular’s Title and Meta services). If your rendering strategy produces server HTML, put the metadata there. It’s more reliable than any client-side update.

Status codes are where SPAs quietly bleed. The server returns 200 for every path so the shell can load, and the app decides client-side whether the route exists. Google warns about this exact pattern: SPAs handling errors client-side “often report a 200 HTTP status code instead of the appropriate status code,” and those error views get indexed as soft 404s. Your “Page not found” component can end up ranking, as thin duplicate content, on hundreds of URLs.

Google sanctions two fixes:

  1. JavaScript-redirect the error state to a URL where the server returns a real 404, e.g. /not-found.
  2. Inject <meta name="robots" content="noindex"> via JavaScript when the route resolves to an error.

There’s a trap hiding in the second one, in reverse. When Google sees noindex in a page’s initial HTML, it may skip rendering and JavaScript execution entirely, so you cannot ship noindex in the shell and remove it with JS for valid pages. Noindex-by-default with client-side removal looks clever and deindexes your whole app.

One more crawler behavior from the same documentation: Google’s Web Rendering Service does not retain state across page loads. Local Storage, Session Storage, and cookies are all cleared. Any content gated on stored client state (a saved location, a dismissed modal, a cached auth token) renders as if it’s the visitor’s first second on the site, every time. Never make indexable content depend on storage.

Core Web Vitals for SPAs: INP Is Your Metric

Among the Core Web Vitals, one is practically designed to catch SPA problems. INP (Interaction to Next Paint) needs to be 200 milliseconds or less at the 75th percentile to score “good,” and it’s measured across the entire page lifecycle. That last part is the SPA-relevant clause: web.dev notes 90% of a user’s time on a page is spent after it loads, which is exactly where SPA soft navigations live. Every client-side route change, filter click, and tab switch feeds your INP score.

Heavy JavaScript bundles are the usual culprit: long main-thread tasks block the browser from painting a response to the user’s input. The practical levers, in order:

Don’t turn this into a six-month performance project. Fix rendering and routing first; they decide whether you’re in the index at all. INP decides how you’re scored once you’re there.

How to Test Whether Your SPA Is Actually Indexable

You can verify all of the above on your own site in about ten minutes. This mini-audit is the first thing we run on any JS-framework lead’s site at SEOBRO, and it separates “we have an SEO problem” from “crawlers literally cannot read the site.”

#CheckHowPass looks like
1Raw HTMLcurl https://yoursite.com/some-view/Your actual copy is in the response; this is exactly what AI crawlers get
2No-JS browseDisable JavaScript in DevTools, reload key pagesContent and navigation still visible
3Google’s renderGSC URL Inspection → View crawled pageRendered HTML contains the full view, no error states
4Index coveragesite:yoursite.com in GoogleInterior views appear with their own titles, not just the homepage
5Error handlingVisit a made-up URL, check the network tabReal 404 status, or a noindexed error view
6Per-view metadataNavigate between views, watch the tab titleTitle and canonical change on every route

Check 1 is the one to run first. Whatever curl returns is the complete universe of what GPTBot, ClaudeBot, and PerplexityBot will ever know about that page. If it’s an empty div and script tags, your content does not exist for AI search, whatever Google manages to render.

If several checks fail and you want the full diagnostic workflow (rendering comparison, log analysis, framework-specific fixes), our JavaScript SEO guide walks through the complete process, and the JavaScript SEO hub collects the framework-by-framework playbooks.

When to Keep the SPA and When to Move Your Marketing Pages Off It

The closing question isn’t technical, it’s commercial: which pages earn you leads, and can crawlers read them?

Here’s the rule of thumb we apply. The product app behind login stays a SPA. It’s the right architecture for dense interactivity, and no crawler needs it. The pages that must capture demand (home, features, pricing, blog, landing pages) need server-rendered HTML from the first byte, because they compete against sites that already serve it.

You have three ways to get there, in ascending order of effort:

  1. Prerender the marketing routes inside your existing app (build-time SSG for those paths, or middleware as a stopgap).
  2. Adopt an SSR framework for the public surface and keep the app as-is behind it.
  3. Split the marketing site off entirely: a static site at the root domain, the SPA at app.yourdomain.com. Cleanest separation, and each side gets the architecture it actually needs.

This is Focused Lead Generation logic applied to rendering: leads come only from pages crawlers can read, so that’s where the engineering budget goes. Optimizing the dashboard for Google is effort spent on a page that will never bring a customer.

If you’d rather have the diagnosis and the fix handled (which rendering strategy fits your stack, which routes to migrate first, and proof via before/after crawl data), that’s exactly what our JavaScript SEO service does. We’ve spent 10+ years doing SEO for 100+ clients across the USA, UK, and EU, and SPAs that “don’t rank” are one of the most common, and most fixable, problems that walk in the door.

Probably, we have already answered your question here

Is React bad for SEO?

01

No. React and every other SPA framework can rank; Google executes JavaScript and indexes well-built single-page apps. What hurts SEO is the default client-side-rendered setup, where crawlers get an empty <div>, views hide behind hash fragments, and error pages return 200. Fix rendering and routing and the framework stops being the problem. Our JavaScript SEO service handles exactly this for React, Vue, and Angular stacks.

Do I need server-side rendering to make an SPA rank?

02

Not usually. For marketing pages whose content only changes on deploy (home, features, pricing, blog), static prerendering at build time is cheaper and more reliable than SSR, and every crawler gets full content from the first byte. Reserve SSR for public content that changes between builds, like listings or user-generated content. The app behind login can stay a pure client-side SPA forever, since no crawler needs to index your dashboard.

How long does Google take to index a JavaScript SPA?

03

Longer than a static page, because rendered pages sit in a queue before indexing. A Vercel and MERJ study of over 100,000 Googlebot fetches found the median rendering-queue time was 10 seconds, but the 90th percentile took around 3 hours and the 99th around 18 hours. Google will see your content eventually, but every added render step is a new failure mode, which is why serving crawlable HTML up front beats depending on the queue.

Why doesn't my SPA show up in ChatGPT or Perplexity?

04

Because AI crawlers download your JavaScript but never execute it. GPTBot, ClaudeBot, PerplexityBot and the rest fetch your bundle and stop, so a pure client-side SPA renders as a blank shell to them and contributes nothing to AI answers about your category. Only Google's Gemini and AppleBot run JS. To appear in AI search you need real HTML in the response, which you can confirm with a plain curl of the page.

Why is my SPA getting indexed as soft 404s?

05

Because the server returns a 200 status for every path so the app shell can load, then the app decides client-side whether the route exists. Google indexes those client-handled error views as soft 404s, so your "Page not found" component can rank as thin duplicate content across hundreds of URLs. Google sanctions two fixes: JavaScript-redirect the error state to a URL where the server returns a real 404, or inject <meta name="robots" content="noindex"> when the route resolves to an error.