SEO for Single-Page Applications
The SPA promise and the SEO problem it creates
A single-page application (SPA) loads one HTML shell and then handles all subsequent navigation client-side — no full page reloads, instant perceived transitions, state preserved across routes. That's a genuine UX win. The SEO cost is that, by default, every route is the same document with JavaScript swapping content in and out, which means the raw HTML response is nearly identical for /products/shoes and /about unless real engineering effort goes into making each route distinguishable to something that isn't executing JavaScript.
Routing: client-side router vs. real URLs
Frameworks like React Router or Vue Router intercept link clicks and update the URL via the History API without a server round-trip. That's fine for navigation within a loaded session, but it creates a hard requirement: the server must still return a valid, meaningful response if someone (or something) requests /products/shoes directly, cold, with no prior JS state loaded. A common and damaging misconfiguration is a server that returns the same generic index.html with a 200 status for every path — technically 'working' for users who navigate from the homepage, but indistinguishable from a 404 for a crawler or a shared link hitting that URL directly.
# Common SPA server config — serves index.html for unmatched routes
# This is necessary for client-side routing to work on direct load,
# but it means the SERVER can't distinguish a real route from a typo
# unless the app itself resolves the route and sets correct status codes.
location / {
try_files $uri /index.html;
}A universal 200 status hides real 404s from crawlers
Meta tags need to change per route, not just content
Title tags, meta descriptions, canonical tags, and Open Graph tags all live in <head>, which a traditional SPA renders once and rarely touches again. Updating only the visible body content on navigation while leaving the original <head> in place means every route shares one title and one meta description — a serious problem both for ranking relevance and for how links preview when shared. Libraries like React Helmet (or framework-native solutions in Next.js, Nuxt, etc.) exist specifically to let each route own its document head, but this has to be deliberately wired in; it doesn't happen automatically just because the content changes.
What breaks by default in a pure client-rendered SPA
| Element | Default SPA behavior | Fix required |
|---|---|---|
| Page title / meta description | Static, shared across all routes | Per-route head management (React Helmet, framework head API) |
| Direct URL load | May return generic 200 shell with no content yet rendered | Server-side or static rendering for first paint |
| Invalid route | Often returns 200 with the same fallback shell | Client or server-side 404 detection and correct status |
| Social share previews | Generic or missing OG tags | Server-rendered OG tags per route |
| Initial content visibility | Empty until JS executes and fetches data | Pre-render or SSR critical above-the-fold content |
Prerendering: render once, serve to crawlers
Prerendering runs the SPA through a headless browser at build time (or on a schedule) and saves the resulting static HTML for each known route. That static snapshot gets served for routes that don't change often — blog-style content, marketing pages, product listings — while the app still behaves as a full SPA for interactive sessions. It's cheap to operate since the rendering work happens once per deploy, not per request, but it's a poor fit for content that's highly dynamic or personalized per visitor, since the snapshot goes stale between builds.
Dynamic rendering: render per-request, but only for crawlers
Dynamic rendering detects the requesting user agent and serves a server-rendered (or headless-browser-rendered) HTML snapshot to known crawlers, while serving the normal client-rendered JS bundle to regular browsers. Google has described this as an acceptable interim workaround for JS-heavy sites that can't yet justify a full SSR migration, though it adds real infrastructure (a rendering service, user-agent detection logic) and it's explicitly a stopgap — it doesn't help other crawlers (Bing, social bots) that aren't on the detection allowlist, and it risks a cloaking-adjacent setup if the two versions diverge in substance rather than just rendering method.
The durable fix is framework-level SSR/SSG, not a crawler workaround
What's next
Rendering strategy matters most when content is stable. The moment a site moves platforms, domains, or URL structures, rendering is just one of many systems that can quietly break — a full migration needs its own checklist.
Next: Site Migration SEO Checklist →
I build these systems professionally.
Whether it's a RAG pipeline, analytics migration, or AI workflow — let's talk.