SEO for single-page apps: help the crawler see what the user sees
A client-rendered single-page app ships an almost-empty <div> and fills it with
JavaScript after the bundle loads. A human browser runs that JavaScript and sees a full
page; a crawler may not. Google’s crawler can execute JavaScript, but it does so on a
delay and a budget, and many other crawlers (social preview bots, smaller search
engines, some AI indexers) do not run it at all — they read the raw HTML and move on.
So the SEO problem for an SPA is a gap: the content the user sees and the content the
crawler first sees are different. If organic traffic or link previews matter, closing
that gap — getting real content into the initial HTML — is the whole job.
Get real content into the initial HTML
The core fix is to render content on the server (SSR) or at build time (SSG/prerender) for the pages that need to rank, so the HTML that arrives already contains the title, the body text, and the metadata — no JavaScript required to see it:
<!-- what the crawler should receive for an indexable page: real content, server-rendered -->
<head>
<title>Blue Running Shoes — Acme</title>
<meta name="description" content="Lightweight blue running shoes, from $89.">
<link rel="canonical" href="https://acme.com/shoes/blue-runner">
</head>
<body><main><h1>Blue Running Shoes</h1><p>Lightweight, breathable…</p></main></body>
A common pragmatic split is SSR/prerender the public, indexable pages (marketing, product, articles) and leave the authenticated app client-rendered, since SEO does not apply behind a login.
Metadata is per-route, not global
An SPA’s biggest SEO footgun is a single, static <title> and <meta> in index.html
for every route — so every page looks identical to a crawler and to a social preview.
Each route needs its own title, description, canonical URL, and Open Graph tags,
rendered into that route’s HTML:
// per-route metadata, rendered on the server for each page
<Head>
<title>{product.name} — Acme</title>
<meta property="og:title" content={product.name} />
<meta property="og:image" content={product.image} /> {/* the link preview image */}
<link rel="canonical" href={`https://acme.com/p/${product.slug}`} />
</Head>
Without this, sharing any page shows the same generic preview, and search engines cannot tell your pages apart.
Help the crawler, and verify what it sees
Round it out with the mechanical helpers crawlers expect: a sitemap.xml listing your
URLs, a sensible robots.txt, real <a href> links (not click handlers that a crawler
cannot follow), and structured data (JSON-LD) for rich results. Then verify rather
than assume — fetch your page with JavaScript disabled, or use a crawler-simulation
tool, and confirm the content and meta are actually there. The failure mode is silent:
the site looks perfect to you (you run JS) and is invisible to the crawler (it may not).
The one-sentence rule is “make the crawler’s first read match the user’s final view,”
and getting there is a rendering-strategy decision more than a keywords one. The
render-strategy-choice exercise is exactly that decision — which pages to render where —
and it is the real lever behind SPA SEO.