Technical SEO

Single-Page Application SEO: Making an SPA Indexable Without Rewriting It

· · 14 min read

Almost every guide to single-page application SEO opens with the same advice: migrate to a meta-framework. It is good advice for a project that has not started. It is useless advice for a team with a shipped product, a roadmap, and a quarter that is already spoken for. This piece is about the other situation — what you can actually do to an SPA you are keeping. It sits in the SEO for engineers series, under the general JavaScript SEO umbrella.

Single-page application SEO is the work of getting an SPA’s content into the HTML response that crawlers receive, without changing the fact that it is an SPA — by prerendering routes at build time, rendering at the edge, or server-rendering a subset of routes, so the pages that need to rank arrive complete while the application shell keeps working exactly as it does now.

Key takeaways

  • The problem is not that it is an SPA. It is that the content arrives after the response. A crawler reads the HTML the server sent. If your content is assembled by JavaScript afterwards, that HTML is an empty shell.
  • You do not have to rewrite anything to fix it. Prerendering, edge rendering, and selective server rendering all leave the application intact and change only how the first response is produced.
  • Fix routes, not applications. Almost no SPA needs every route indexed. The marketing pages, the content, and the public catalogue need it; the logged-in application does not, and never did.
  • Googlebot will usually cope. AI crawlers will not. Google defers rendering to a second pass. GPTBot, ClaudeBot and PerplexityBot do not render at all, so a client-rendered route is permanently invisible to them.
  • Client-side routing needs real URLs and real links. History-API routes with server fallbacks and genuine anchor elements, not hash fragments and click handlers.

Are single-page applications bad for SEO?

No — and the honest version of that answer is more useful than either the reassurance or the scare story.

An SPA is a way of handling navigation after the first page load. That has nothing to do with SEO. What causes SEO problems is the thing most SPAs also do, which is assemble the first page in the browser too. Those are separable decisions, and separating them is the entire fix. You can have client-side routing and server-delivered first paint at the same time; that combination is what every meta-framework produces, and you can produce it without adopting one.

So the accurate framing is: an SPA whose first response contains the page is fine. An SPA whose first response is an empty container has a problem, and the problem is not architectural taste — it is that a crawler asked for a page and received a loading state.

Check which one you have in ten seconds, with no tooling:

curl -s https://example.com/pricing | grep -iE "<h1|<title"

If the heading comes back, you are fine. If you get a bare root element and a script tag, keep reading. Then run it again as an AI crawler, because that answer matters more and is often different in consequence:

curl -sA "GPTBot" https://example.com/pricing | grep -i "<h1"

Googlebot processes pages in three phases — crawl, render, then index — with rendering deferred to a later pass, so a client-rendered route can still be indexed, late and sometimes partially. GPTBot, ClaudeBot and PerplexityBot never render. For those engines a client-rendered route is not slow to appear; it does not exist.

Three routes that do not require a rewrite

Ranked by how little they disturb what you have.

1. Prerender the routes that need to rank

The cheapest fix, and the one most teams overlook because it sounds like it should be harder than it is. Prerendering runs your application once per route at build time and writes the resulting HTML to a file. A CDN serves those files. The crawler gets a complete document. The browser then hydrates and your SPA behaves exactly as before.

You are not adding a server. You are adding a build step.

This works when two conditions hold, and fails quietly when they do not:

  • The routes must be enumerable at build time. Prerendering walks a list. If your routes come from a CMS or an API, the build needs access to that same source to generate the list.
  • Data fetching must work outside the browser. Code that only fetches inside an effect hook will render the empty state during prerender, and you will ship a static file containing your loading spinner. This is the single most common way a prerender setup produces nothing useful while appearing to succeed. Always read one generated file before trusting the pipeline.

Best for marketing pages, documentation, blog content, and any catalogue that changes on a deploy cadence rather than per request.

2. Render at the edge

If content changes too often to bake at build time but does not need a full origin server, an edge function can render the first response and hand off to the client bundle. The application code is unchanged; a small function in front of it produces HTML for the initial request.

This buys you freshness that prerendering cannot, without operating a rendering server in the traditional sense. The costs are real though: you are running your framework’s renderer in a constrained runtime, cold starts affect Largest Contentful Paint, and any code path that assumes Node APIs or a DOM has to be made portable.

Best for content that changes hourly, personalised-but-cacheable pages, and large catalogues where a full prerender would take too long.

3. Server-render a subset of routes

The heaviest option and still not a rewrite. Put your framework’s server renderer in front of the routes that need it and leave everything else client-rendered. Most SPA frameworks expose this directly, and the pattern is the same everywhere: render to a string or stream on the server, hydrate the same component tree in the browser.

The work is rarely the rendering itself. It is making the components server-compatible — anything touching window, document, localStorage or navigator at module scope or during initialisation will throw where none of those exist. That refactor is the actual cost, and it is proportional to how much of the app you bring into the server path, which is a good reason to bring in as little as possible.

Best for pages that genuinely depend on per-request data: search results, personalised listings, anything with live pricing.

Choosing between them

PrerenderingEdge renderingSelective SSR
FreshnessPer deployPer request, cachedPer request
Infrastructure addedNone (build step)Edge functionRendering server
Refactor requiredData fetching must run at buildComponents must be runtime-portableComponents must be server-safe
Fails byShipping the loading stateCold starts hurting LCPServer-only crashes
Best forMarketing, docs, blogFrequently changing contentPer-request data

Start at the left and move right only when the content genuinely demands it. Most teams need column one for most routes and never need column three at all.

One option deliberately absent from that table: dynamic rendering, the pattern of serving a prerendered copy to bots and the SPA to humans. Google now states that dynamic rendering was a workaround and not a long-term solution, and recommends server-side rendering, static rendering or hydration instead. If you already run it, the three columns above are your migration targets.

Decide per route, not per application

This is the part that turns an intimidating project into a small one.

Take your route list and sort it into three buckets. Must rank — marketing pages, the blog, public product or catalogue pages, anything you would be upset to lose from Google. Should be reachable — pages that need to exist and be linked but are not traffic drivers. Must never be indexed — everything behind authentication: dashboards, settings, account pages, internal tools.

Only the first bucket needs any of the work above. The third bucket should stay client-rendered, because that is the correct architecture for an application, and it should carry noindex besides. For most SPAs the first bucket is a dozen routes out of hundreds, which means the project is a day of work rather than a migration.

The mistake is treating “our SPA has an SEO problem” as a statement about the application. It is a statement about a handful of routes. The same per-template logic applies in every stack, and the general treatment is in rendering strategies for headless architecture.

The routing and linking rules that still apply

Rendering is necessary and not sufficient. Three structural things break SPAs independently of how the HTML is produced.

Use the History API, not hash fragments. Routes of the form example.com/#/pricing put the route in the fragment, and Google’s URL guidance is explicit that it generally does not support URL fragments to change page content. Every hash route collapses to the same underlying URL, so the entire site reads as one page. Use real paths, and configure the server to return the application for any unmatched path so a direct request to /pricing works.

Navigation must be real anchors. A <div> with a click handler that calls the router is invisible to a crawler — there is no href to follow, so the destination may never be discovered. Every framework’s router provides a component that renders a genuine anchor with an href; use it, and let it intercept the click for client-side navigation. Real link, enhanced behaviour.

Return real status codes. A client-side “not found” view served with a 200 is a soft 404. A route that does not exist should produce a real 404 from the server, because the status code is the only unambiguous signal — a rendered message saying “not found” is just text on a successful page. Likewise, redirects belong in server responses, not in a window.location assignment after load.

Those three plus a canonical tag in the server response — not injected later — cover the structural layer. Canonicals in particular are unforgiving about arriving late; the decision framework is in canonical vs noindex.

Metadata and structured data have to arrive with the page

The same rule governs everything in the <head>. A title, description, canonical or JSON-LD block that a component sets after mount is a change to a document the crawler already read.

On a prerendered or server-rendered route this works automatically, because the component runs on the server and its output is in the file. On a client-rendered route none of it counts, no matter which metadata library produced it. This is why “we added a meta tag manager and nothing improved” is such a common report: the library was working correctly and running too late.

Structured data has the sharpest version of this problem, because it is pure machine-readable payload with no visible symptom when it fails. JSON-LD appended to the document in an effect hook is invisible to anything reading the raw response — and to the AI engines that never render, permanently so. Emit it into the server output. Which schema types actually earn the effort is covered in structured data for AI search.

Framework-specific routes out

The three approaches above are framework-agnostic, but the implementation details are not, and the defaults differ sharply:

  • React SEO — the shell trap in its purest form, the three escape routes with code, and build-time prerendering for a Vite app you are not migrating.
  • Angular SEO — a framework whose own documentation states that client-side rendering is the default and rates its SEO impact as poor, plus the metadata services that run too late on a CSR route.
  • Next.js SEO — App Router server components, generateMetadata, and server-rendered schema.
  • Astro SEO — the case where the shell trap never opens, because the default output ships no JavaScript at all.

If you are choosing rather than fixing, that last one is worth reading first: much of the work in this article exists to reproduce, in an SPA, the posture a static-first framework starts from.

Verifying it worked

An SPA looks identical in a browser whether the fix landed or not, so verification cannot be visual. Three checks:

The raw fetch. curl each route in the “must rank” bucket and confirm the H1, title, canonical and JSON-LD are present. This is also exactly what an AI crawler receives, so a pass here covers both audiences at once.

URL Inspection’s rendered HTML. In Search Console, test the live URL and read the HTML tab. This is Googlebot’s own renderer and the only authority on what Google sees after rendering. Do not substitute your browser’s DevTools — your browser has resources and patience that Googlebot does not guarantee.

A crawl with JavaScript rendering disabled. Crawl the site twice, once rendered and once not. Every field that appears only in the rendered crawl is content that only a rendering engine can see. On a correctly fixed SPA, the two crawls should look nearly identical across the routes you care about ranking, and the diff between them is your remaining work.

Re-run all three after any change to the build or rendering configuration. The failure mode here is silent: the application keeps working perfectly for humans whether or not the first response contains anything useful.

Where this fits

Single-page application SEO is one shape of the general problem covered in JavaScript SEO — how crawlers process JavaScript, and why AI crawlers changed the stakes. The standing version of these checks, run on a cadence rather than once, belongs in the technical SEO audit methodology, because a refactor can quietly push content back onto the client months after you fixed it.

If you would rather have the render strategy scoped and shipped than retrofitted under deadline, that is what I do: GEO and technical SEO consulting covers the crawl and citation layers, and full-stack development covers building it into the codebase.

FAQ

Are single-page applications bad for SEO?

Not inherently. An SPA is a way of handling navigation after the first load, which has no SEO consequence by itself. The problem is that most SPAs also build the first page in the browser, so the HTML a crawler receives is an empty container. Those two things are separable: keep client-side routing and deliver the first response complete, via prerendering, edge rendering or selective server rendering. An SPA whose first response contains the page ranks like any other site.

Can Google index a single-page application?

Usually, yes. Googlebot runs an evergreen Chromium and renders JavaScript on a deferred second pass, so client-rendered content can be indexed — a Vercel and MERJ study found Google rendered 100% of indexable HTML pages it measured. Two caveats make relying on it a poor plan. Rendering is queued rather than immediate, so content can be indexed late or incompletely. And AI crawlers such as GPTBot, ClaudeBot and PerplexityBot do not render at all, so client-rendered routes are permanently invisible to ChatGPT, Claude and Perplexity rather than merely delayed.

Do I need server-side rendering for SPA SEO?

Often not. Server-side rendering is the heaviest of three options and is only necessary when a page genuinely depends on per-request data. For marketing pages, documentation and blog content — which is what most sites actually need indexed — build-time prerendering produces the same crawler-visible HTML with no server to operate and no runtime rendering to go wrong. Reach for SSR when the content cannot be known at build time, not by default.

How do I do SEO for a single-page app without migrating to Next.js?

Prerender the routes that need to rank. Your build runs the application once per route and writes real HTML files; a CDN serves them; the browser hydrates as usual. No framework migration, no server. The two prerequisites are that your routes are enumerable at build time and that data fetching can run outside the browser — if fetching happens only inside an effect hook, you will prerender your loading spinner instead of your content.

Does hash routing hurt SEO?

Yes, significantly. Hash-based URLs put the route in the fragment, and Google’s URL-structure guidance states that it generally does not support URL fragments to change page content. From a crawler’s point of view every hash route resolves to the same underlying URL, so a site with a hundred views has one page. Switch to History API routing with a server fallback before spending effort on anything else.

Should the logged-in part of my app be indexed?

No, and trying to make it indexable is the most common way SPA SEO projects waste time. Authenticated dashboards, settings and internal tools should stay client-rendered, which is the correct architecture for an application, and should carry noindex. Sort your routes into must-rank, should-be-reachable and never-index buckets, and only do rendering work on the first. For most applications that is a dozen routes out of hundreds.