TapCub is live — analytics, insights and live chat, free on one platform
AI Search

Single-page application SEO: routing, status codes and analytics

Single-page apps need every client-side view to behave like a real page for crawlers and analytics. Seven requirements, history vs hash routing, soft 404 fixes, a migration that grew indexed pages from 15 to 271, and how to count SPA pageviews correctly.

Xu An Published Nov 5, 2026 7 min read AI Search
Single-page application SEO: routing, status codes and analyticsSample data

In a single-page app, every view is created in the browser, so URLs, titles, status codes and pageviews all need deliberate work. This guide lists the seven requirements, explains hash vs history routing and soft 404s, and walks through a migration where indexed pages grew from 15 to 271 while pages per session jumped for a different reason: it was finally measured.

What you will learn

  • Give every public view a real URL with history routing
  • Ship content, title and canonical in the first HTML response
  • Return real 404s; client-side "Not found" pages are soft 404s
  • Paginate infinite lists so every item has a crawlable URL
  • Track one pageview per route change and annotate migrations

What makes single-page application SEO different?

A single-page application (SPA) loads one HTML document and then swaps content in the browser with JavaScript as the user navigates, instead of requesting a new page from the server for every click. React, Vue, Angular and Svelte apps are usually built this way. SPA SEO is the set of practices that makes each of those client-side "pages" behave, for crawlers and analytics, like a real page: its own URL, its own HTML content and metadata, its own status code and its own pageview.

General rendering and crawling concerns are covered in JavaScript SEO. This guide focuses on the problems specific to SPAs: routing, per-route metadata, status codes, infinite lists and analytics.

The seven SPA SEO requirements

#RequirementWhat goes wrong in a default SPAFix
1A real URL per viewViews share one URL or use /#/ fragmentsHistory API routing (/pricing, not /#/pricing)
2Content in the first HTML response<div id="root"></div> and nothing elseSSR, static generation or prerendering for public routes
3Unique title, description, canonical per routeEvery route keeps the index.html titleSet head tags per route on the server (Next.js metadata, Nuxt useHead, Angular Meta/Title with SSR)
4Correct status codesUnknown routes render "Not found" with HTTP 200Server returns 404 for unknown paths, 301 for moved ones
5Crawlable linksNavigation via onClick handlers<a href> elements (router Link components render them)
6Paginated, not only infinite, listsItems beyond the first batch never get a URLPaginated URLs (?page=2) linked from the list
7A pageview per route changeAnalytics records one pageview per visitTrack History API changes as pageviews
RequirementFix
Real URL per viewHistory API routing
Content in first HTMLSSR, SSG or prerender
Unique head tagsPer-route metadata on the server
Correct status codesServer 404 / 301
Crawlable links elements
Paginated lists?page=N URLs
Pageview per routeTrack History API changes

Hash routes (/#/) are one URL to search engines.

Routing: history mode vs hash mode

Hash routing (example.com/#/pricing) works without server configuration, which is why many SPAs start with it. For search, everything after # is a fragment of the same document, so Google treats /#/pricing and /#/features as one URL. Use history mode (example.com/pricing) and configure the server to return the right document and status for each path. Keep hash routing only for views that should never be indexed (an internal tool, a logged-in dashboard).

Status codes and soft 404s

In a client-only SPA the server returns the same index.html with HTTP 200 for every path, including /products/does-not-exist. The app then shows "Not found", but crawlers see a 200 page with thin content, which Google reports as a soft 404 and which wastes crawl budget.

Fixes, in order of preference:

  1. With SSR, return 404 (or 410 for permanently removed items) from the server when the data lookup fails.
  2. Without SSR, keep a server-side allowlist of valid route patterns and return 404 for anything else.
  3. As a last resort, add <meta name="robots" content="noindex"> on the not-found view, which Google will honor when it renders. It does not fix the status for other crawlers.

Redirects in SPAs often happen in JavaScript (router.push). Replace permanent ones with server-side 301s, and avoid redirect chains.

Worked example: an SPA migration, before and after

A SaaS marketing site and help center ran as one client-rendered Vue app with hash routing (sample data, 30 days each):

MetricBefore (hash routing, CSR)After (history routing, SSR)
URLs Google could see1 (/) plus 14 linked PDFs286
Pages indexed15271
Unknown paths returning 200All0 (now 404)
Organic landing pages with traffic3164
Organic sessions1,2406,930
Recorded pages per session1.13.4
Recorded bounce rate78%41%

Two different things changed. The SEO change created indexable URLs, so organic traffic grew 5.6×. The analytics change (pageviews on route changes) fixed measurement: before, a visitor who read five help articles was recorded as one pageview and a bounce. Pages per session and bounce rate moved mainly because they were measured correctly, not because behavior changed. When you migrate, annotate the release so nobody reads the jump as a real improvement.

app.tapcub.com/analytics/pages

Indexed pages

15 → 271

▲

Organic sessions

×5.6

▲

Pages per session

1.1 → 3.4

▲ measurement fix

SPA migration · 30 days before vs after

Hash CSR → history SSR
MetricBeforeAfter
URLs visible to Google1 + 14 PDFs286
Pages indexed15271
Organic landing pages3164
Organic sessions1,2406,930
Pages per session1.13.4
Bounce rate78%41%

Sample data. Pages per session and bounce moved mostly because route changes started being counted.

Sample data

Analytics for single-page apps

  • Track a pageview on every route change, including the page title and full path after the route resolves, not before.
  • Do not double count. Some routers fire several history events per navigation; dedupe identical consecutive URLs.
  • Keep the referrer chain. Each virtual pageview's referrer should be the previous route.
  • Watch engagement time. In SPAs the tab stays open across many views; time is split per route on route change.
  • Measure real-user performance per route. Soft navigations do not trigger a new page load; first-load LCP applies to the landing route, while later routes depend on INP and data fetching.

SPA SEO checklist

  • [ ] Public routes use history mode, not # fragments
  • [ ] Raw HTML of each public route contains its main content, title, description and canonical
  • [ ] Router links render as <a href>
  • [ ] Unknown paths return 404; removed items return 404 or 410; moved items return 301
  • [ ] Sitemap lists every indexable route; no logged-in routes in it
  • [ ] Infinite lists have paginated URLs
  • [ ] robots.txt does not block JS, CSS or API paths needed for rendering
  • [ ] Analytics records a pageview per route change, once

SPAs in TapCub

TapCub's script sends the first pageview when the document is ready and hooks later URL changes made with the History API, so React Router, Vue Router, Next.js and Nuxt routes are tracked without extra code; the same URL is not sent twice in a row. For hash routing, add data-hash="true" on the script tag. To control timing yourself, set data-auto-track="false" and call tapcub.pageview() after each route resolves. See installing on single-page apps, plus the React, Vue, Next.js and Nuxt guides. After a routing change, mark it with a chart annotation.

See also: Indexability, Soft 404.

Sources

Was this article helpful?

We use your answers to fix and expand this page.

Xu An

Customer Experience Consultant

Writes about live chat, visitor conversations, and self-service, sharing practical ways to make customer communication clearer and more effective.

Count every route, not just the first load

History API route changes become pageviews automatically; hash routes with one attribute.

See your data clearly. Find your growth.

Every click, backed by data. Install one line of code and see your first numbers in a minute.

No credit card · Free plans for analytics and chat · Cookieless analytics