Skip to the content.

SEO for single-page apps: help the crawler see what the user sees

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.

A crawler reads the initial HTML; a CSR app's initial HTML is empty Left: CSR ships an empty div, so a non-JS crawler sees nothing. Right: SSR/prerender ships real content in the HTML, so the crawler indexes what the user sees. CSR: empty HTML <div id="root"></div> non-JS crawler sees nothing SSR/prerender <h1>Real title</h1>real content + meta crawler indexes it
The crawler's first read is the raw HTML. CSR hands it an empty div; SSR or prerendering hands it the real content and meta, so what gets indexed matches what users see.

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.