SPA SEO without SSR: per-route metadata, canonicals and JSON-LD
A client-rendered Vite/React SPA can rank and share well without adopting Next.js. What Googlebot actually does with JavaScript, why social crawlers do not, and a pragmatic stack: per-route meta, canonical tags, structured data and pre-rendering.
The advice "SPAs are bad for SEO, use SSR" is a decade old and now only half true. Google renders JavaScript. But there is a real gap between "Google can eventually index it" and "this page shows the right title in search, the right card on LinkedIn, and qualifies for rich results". That gap is closeable without a framework migration.
What crawlers actually do#
| Consumer | Runs your JS? | Implication |
|---|---|---|
| Googlebot | Yes, renders in a headless Chromium, but on a queue with a budget | Client-set meta works, but static is faster to index and more reliable |
| Bingbot | Limited / inconsistent | Do not rely on client rendering |
| LinkedIn / Slack / iMessage / WhatsApp / Discord | No, they read the raw HTML response only | Client-set og: tags are invisible; the crawler sees index.html |
| Twitter/X card bot | No | Same; needs tags in the initial HTML |
| No | Same |
useEffect exists in the DOM, butnot in the HTTP response. Social crawlers only ever read the HTTP response.The four layers that matter#
- Per-route
<title>andmeta description, for search result text. - Per-route Open Graph / Twitter tags, for social cards.
- Canonical URLs and a sitemap, for crawl correctness and deduplication.
- Structured data (JSON-LD), for rich results.
Layers 1 and 4 work acceptably when set client-side (Google renders them). Layers 2 and 3 want to be in the static HTML. That split is what decides your approach.
Setting per-route metadata client-side#
React 19 hoists <title> / <meta> rendered anywhere in the tree, so you can just render them in a component. On React 18, use react-helmet-async or a small effect that upserts tags and reverts them on unmount:
function useSeo({ title, description, path, jsonLd }) {
useEffect(() => {
const prev = document.title
document.title = title + " | Site Name"
const undo = [
() => (document.title = prev),
upsertMeta("name", "description", description),
upsertLink("canonical", "https://example.com" + path),
upsertMeta("property", "og:title", title),
upsertMeta("property", "og:description", description),
upsertMeta("property", "og:url", "https://example.com" + path),
jsonLd && appendJsonLd(jsonLd),
]
return () => undo.forEach((fn) => fn && fn())
}, [title, description, path, jsonLd])
}This is enough for Google Search on a small content site. It is not enough for social preview cards or Bing. For those you need the tags in the response body.
Getting tags into the static HTML#
Three ways, from least to most involved:
1. Per-page HTML at build time (best for a fixed set of routes)
A blog with a known list of posts can emit one HTML file per route at build time, each with its own <head>. Options in the Vite ecosystem:
vite-react-ssgorvite-plugin-ssr/ Vike: render each route to static HTML, hydrate on load.- A small post-build script that reads your route list and writes
dist/blog/<slug>/index.htmlwith injected<head>tags. - Framework-level: Astro, or TanStack Start / Next in SSG mode, if you are willing to migrate.
// scripts/prerender-heads.mjs: sketch of the DIY approach
import { posts } from "../src/blog/manifest.js"
import { readFileSync, writeFileSync, mkdirSync } from "node:fs"
const shell = readFileSync("dist/index.html", "utf8")
for (const p of posts) {
const head = [
`<title>${p.title} | Site Name</title>`,
`<meta name="description" content="${p.description}">`,
`<link rel="canonical" href="https://example.com/blog/${p.slug}">`,
`<meta property="og:title" content="${p.title}">`,
`<meta property="og:description" content="${p.description}">`,
`<meta property="og:type" content="article">`,
].join("\n ")
const html = shell.replace("<!--HEAD-->", head)
mkdirSync(`dist/blog/${p.slug}`, { recursive: true })
writeFileSync(`dist/blog/${p.slug}/index.html`, html)
}Your host must then serve /blog/<slug>/index.html for that path and only fall back to the SPA index.html for unknown routes.
2. Dynamic prerendering for crawlers (Prerender.io style)
A middleware/CDN rule detects bot user-agents and serves a pre-rendered snapshot while humans get the SPA. Effective but adds a moving part and a cache to manage, and "cloaking" concerns mean the snapshot must match what users see.
3. Full SSR
The complete answer, and overkill for a portfolio or docs site. Adopt it when content is personalised, changes per request, or first-paint performance on slow devices is a business metric.
| Situation | Do this |
|---|---|
| Small content site, fixed routes, static host | Client-side meta + build-time per-route HTML for shareable pages |
| Marketing site, SEO is the point | SSG (Astro / vite-react-ssg) from the start |
| App with a few public pages + big private area | Prerender/SSG the public pages, leave the app a pure SPA |
| Personalised, per-request content | SSR |
Structured data (JSON-LD)#
For articles, BlogPosting is the schema to emit. Google reads JSON-LD from the rendered DOM, so a client-injected <script type="application/ld+json"> is fine here:
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": "SPA SEO without SSR",
"description": "Per-route metadata, canonicals and JSON-LD for client-rendered apps.",
"datePublished": "2026-04-08",
"dateModified": "2026-04-08",
"author": { "@type": "Person", "name": "Your Name", "url": "https://example.com" },
"mainEntityOfPage": "https://example.com/blog/spa-seo-without-ssr"
}Validate with Google's Rich Results Test and schema.org validator before shipping.
The pragmatic checklist#
- One
<Seo>component per route: title, description, canonical, OG/Twitter, JSON-LD, reverting on unmount. - A real, current
sitemap.xmllisting every indexable route; reference it inrobots.txt. noindexon the 404 route (a client 404 still returns HTTP 200).- For pages people will share: static
<head>tags in the HTML response, via SSG or a post-build script. - Absolute URLs in
og:image,og:urland canonical, never relative. - One canonical per route, self-referencing, no trailing-slash ambiguity.
- Test with the actual tools: URL Inspection in Search Console, the LinkedIn Post Inspector, and
curl -Awith a bot user-agent to see the raw HTML a crawler gets.
in HTTP response? set in useEffect?
───────────────── ────────────────
Google Search title preferred works (rendered)
Google rich results preferred works (rendered)
LinkedIn / Slack card REQUIRED invisible
Twitter/X card REQUIRED invisible
Bing REQUIRED unreliable<head> tags into the static HTML with SSG or a build-time per-route script.