Portfolio/Writing/SPA SEO without SSR: per-route metadata, canonicals and JSON-LD

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#

ConsumerRuns your JS?Implication
GooglebotYes, renders in a headless Chromium, but on a queue with a budgetClient-set meta works, but static is faster to index and more reliable
BingbotLimited / inconsistentDo not rely on client rendering
LinkedIn / Slack / iMessage / WhatsApp / DiscordNo, they read the raw HTML response onlyClient-set og: tags are invisible; the crawler sees index.html
Twitter/X card botNoSame; needs tags in the initial HTML
FacebookNoSame
The core problem in one sentence
Anything your React app sets in useEffect exists in the DOM, butnot in the HTTP response. Social crawlers only ever read the HTTP response.

The four layers that matter#

  1. Per-route <title> and meta description, for search result text.
  2. Per-route Open Graph / Twitter tags, for social cards.
  3. Canonical URLs and a sitemap, for crawl correctness and deduplication.
  4. 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:

jsx
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-ssg or vite-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.html with injected <head> tags.
  • Framework-level: Astro, or TanStack Start / Next in SSG mode, if you are willing to migrate.
js
// 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.

SituationDo this
Small content site, fixed routes, static hostClient-side meta + build-time per-route HTML for shareable pages
Marketing site, SEO is the pointSSG (Astro / vite-react-ssg) from the start
App with a few public pages + big private areaPrerender/SSG the public pages, leave the app a pure SPA
Personalised, per-request contentSSR

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:

json
{
  "@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.xml listing every indexable route; reference it in robots.txt.
  • noindex on 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:url and 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 -A with a bot user-agent to see the raw HTML a crawler gets.
text
                         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
Where each tag needs to live to be seen.
Summary
You do not need SSR to do SPA SEO well. Set per-route metadata and JSON-LD client-side for Google, keep an accurate sitemap and canonicals, and for anything meant to be shared, get real <head> tags into the static HTML with SSG or a build-time per-route script.

Next up
Micro-frontend integration patterns: Module Federation vs import maps vs build-time

A practical comparison of the ways to compose a front-end from independently owned pieces (npm packages, server-side composition, Module Federation, native import maps, web components), and how to pick one.

Read next →