Skip to content

SEOPart of: JavaScript SEO

SEO for single page applications: URLs, titles and status codes

SEO in a single page application depends on treating every view as a real page: its own URL, its own title and canonical, crawlable links and an honest status code. How to get each right, whatever framework you use.

getReport teamUpdated 26 Sept 202610 min read

SEO in a single page application (SPA) works when every view behaves like a real page: it has its own path, answers a direct request with the right content, carries its own title and canonical, links to other views with ordinary <a href> links and returns an honest status code. An SPA loads one HTML document and swaps views with JavaScript, which is great for visitors and confusing for crawlers, because a crawler never clicks through your app; it requests each URL cold. This guide covers the rules that apply to any SPA, whether it is built with React, Vue, Angular, Svelte or plain JavaScript. It is part of our guide to JavaScript SEO and how crawlers read script-built sites.

Quick answer

  • One path per view, using the History API. /shoes/blue-runner/, not /#/shoes/blue-runner.
  • Every URL must work on a direct request, with the right content, not only after navigating from the home page.
  • Title, description, canonical and robots per route, in the HTML the server sends if at all possible.
  • Links are <a href> elements with real paths; the router intercepts the click.
  • Missing views return 404, or, where the server cannot know, get a noindex or a redirect to a URL that does.
  • Prerender public routes to their own HTML files when you host on static hosting.
  • Check any route with the free JavaScript SEO check, which shows the raw HTML next to the rendered view.

How crawlers experience an SPA

A visitor lands on your home page, the app boots, and from then on every click is handled in the browser: the router swaps the view, fetches data and updates the address bar. The server is asked for index.html once.

A crawler never does that. Googlebot collects URLs from links, sitemaps and redirects, then requests each one on its own, with no cookies, no local storage and no memory of the previous page. Google's JavaScript SEO basics documentation describes this stateless behaviour. So /shoes/blue-runner/ has to produce the blue runner page from a cold start. If the app only knows which product to show because the visitor clicked it on the category page, the direct request shows nothing useful.

Then there is rendering. If the server sends the same near-empty index.html for every path, Google has to render each URL in its later rendering step to see anything; the guide on how Google renders JavaScript content explains that queue. AI crawlers and link previews do not render at all. The strongest fix is to send real HTML per route, which the frameworks in our React SEO and Angular SEO guides do with server rendering or prerendering. The rest of this guide covers what every SPA has to get right, rendered on the server or not.

Routing: real paths, not fragments

Use the History API (history.pushState), which every modern router supports: createBrowserRouter in React Router, createWebHistory in Vue Router and the default PathLocationStrategy in Angular.

Avoid hash routing (HashRouter, createWebHashHistory, HashLocationStrategy) for public pages. Everything after # is a fragment, which browsers do not send to the server and which Google ignores when it separates pages. /#/shoes and /#/shirts are the same URL to Google: the home page. Google's old AJAX crawling scheme with #! URLs was deprecated in 2015 and is no longer supported.

Path routing needs one server change: every public path must return something. On static hosting that usually means a rewrite that serves index.html for unknown paths, which brings its own status code problem, covered below.

Titles, descriptions and canonicals per route

Every indexable view needs its own:

  • <title> naming the page ("Blue trail runner | Example Shop"), not the app.
  • <meta name="description"> written for that page.
  • <link rel="canonical"> pointing at the view's own clean URL.
  • <meta name="robots"> only where a view must stay out of search.
  • Open Graph tags, if the page is shared.

The best place for these is the HTML the server sends for that path, through server rendering or prerendering. Only then do link previews and AI crawlers see them.

If the app can only set them in the browser, Google can still read them after rendering: it reads a title and description set with JavaScript, and its documentation says it can pick up a canonical injected with JavaScript, while recommending that you do not rely on it. Two rules make the browser-only approach less risky:

  1. Do not ship conflicting defaults. If index.html has <link rel="canonical" href="https://example.com/">, every route starts life canonicalised to the home page. Leave the canonical out of the shell and add one per route, or better, set the right one on the server.
  2. Never put noindex in the shell. Google may skip rendering a page that has noindex in its HTML, so a script that removes it never runs.

The js-seo-tags-changed finding in the report flags exactly these conflicts:

Googlebot follows <a> elements with an href and nothing else. Router link components (<Link>, <RouterLink>, routerLink) render real anchors, so use them. The patterns that break crawling are custom:

  • a product card with onClick={() => navigate(url)} on a <div>;
  • a menu of <button> elements;
  • an <a> without href, or with href="#" or href="javascript:void(0)";
  • pagination or "load more" that only exists as a click handler (our infinite scroll SEO guide shows the crawlable version).

Real links are better for people too: they open in a new tab, show the destination on hover and work with assistive technology.

Status codes and soft 404s

A traditional server answers 404 for a missing page. An SPA with a catch-all rewrite answers 200 OK and index.html for every path, then shows "Product not found" in JavaScript. Google sees a successful page with almost no content: a soft 404. Every mistyped or outdated URL becomes a candidate for the index, and real errors are hidden.

Fixes, best first:

  1. Let the server decide. With server rendering, the framework knows whether the product exists before sending anything, and can answer 404 (and 301 for moved pages). Each framework guide above shows how.
  2. Prerender known routes, 404 the rest. On static hosting, generate an HTML file for every public route at build time and serve a 404.html with status 404 for everything else, instead of rewriting every path to index.html.
  3. If the server cannot know, use one of the two workarounds Google documents for SPAs: redirect with JavaScript to a URL that answers 404 (such as /not-found), or add <meta name="robots" content="noindex"> to the error view with JavaScript.
JavaScript
// In the router's not-found handler of a client-only SPA
const robots = document.createElement('meta');
robots.name = 'robots';
robots.content = 'noindex';
document.head.appendChild(robots);

The general guide to soft 404s covers how Search Console reports them. Moved pages need the same honesty: redirect them on the server or CDN with a 301, not with a client-side navigate(), which Google only sees after rendering.

Static hosting: rewrites and fallbacks

Most static hosts have an "SPA mode" that serves index.html for every unknown path:

  • Netlify: a _redirects rule such as /* /index.html 200.
  • Vercel: a rewrites entry that sends every path to /index.html.
  • Cloudflare Pages: when the project has no top-level 404.html, Pages treats it as an SPA and serves index.html for unmatched paths.

All three make every URL answer 200. That is fine for an app behind a login. For public pages, prefer prerendering each route to its own index.html in a matching folder (/shoes/blue-runner/index.html) and keeping a real 404.html. Most build tools can do this: React Router's prerender option, Angular's prerender mode, Nuxt's routeRules, SvelteKit's prerendering, or a dedicated prerender plugin for Vite. Prerendering at build time is not the same as dynamic rendering for bots, which Google calls a workaround; see our guide to dynamic rendering and prerendering for the difference.

Accessibility and analytics on route changes

Two things break in every SPA that search engines never notice but people do:

  • Screen readers are not told the page changed. After a route change, move focus to the new page's <h1> or a wrapper, or announce the new title in an aria-live region. Update document.title on every route; some routers do this for you.
  • Analytics count one page view. Send a virtual page view on every route change, or your reports undercount pages per visit.

How to check a single page application

Test a deep URL, not only the home page. Paste a product or article URL into the check:

It requests the URL cold, like a crawler, then renders it in Chromium without clicking or scrolling, and compares title, description, canonical, robots, headings, links, word count and JSON-LD. In a client-only SPA the raw column shows the shell; the rendered column should show the right product. If the rendered column shows the home page or an empty state, the view does not work on a direct request.

Then:

  1. Request a made-up path such as /shoes/does-not-exist/ with curl -I. The status should be 404.
  2. Search the rendered page's links for href="#" and javascript: values.
  3. In Google Search Console, use URL Inspection → Test live URL on a deep URL and look at the rendered HTML and screenshot.
  4. Check the Pages report for "Soft 404" and "Duplicate, Google chose different canonical than user" entries, which often point at shell-level canonicals.

Common mistakes

  • Hash routes on public pages.
  • A canonical to the home page in index.html, inherited by every route.
  • Views that depend on in-app state and show nothing on a direct request.
  • 200 for every path because of the SPA fallback.
  • Client-side redirects for moved URLs.
  • Content in localStorage or behind a consent click, which Googlebot never has.
  • Testing only by clicking through the app, which never exercises direct requests.

Questions people ask

Can a single page application rank in Google?

Yes. Google renders JavaScript and indexes SPA views that have their own URL, work on a direct request and are linked with real <a href> links. Rankings are more reliable when each route also reaches crawlers as HTML through server rendering or prerendering, because that removes the delay of Google's rendering step and makes the pages readable to AI crawlers and link previews that do not run JavaScript.

How do I fix soft 404s in a single page application?

Make missing routes answer a real 404. With server rendering or prerendering, the server knows which routes exist and can return the status itself. On a client-only SPA, Google documents two workarounds: redirect with JavaScript to a URL whose server response is 404, or add a noindex robots meta tag to the error view. Also remove catch-all rewrites for public paths where you can.

Do hash URLs work for SEO in an SPA?

No. Everything after # is a fragment that browsers do not send to the server and Google ignores when it tells pages apart, so /#/shoes and /#/shirts count as one URL, the home page. The old #! AJAX crawling scheme was deprecated in 2015. Switch the router to History API mode and configure the server so every path returns the right page.

Check your site before and after Check