# 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.

Updated 2026-09-26 · Technical SEO · HTML version: https://getreport.app/guides/single-page-application-seo

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](https://getreport.app/guides/javascript-seo).

## 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](https://getreport.app/tools/js-rendering-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](https://developers.google.com/search/docs/crawling-indexing/javascript/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](https://getreport.app/guides/javascript-rendered-content-and-google) 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](https://getreport.app/guides/react-seo) and [Angular SEO](https://getreport.app/guides/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:

> **Check: JavaScript leaves the title, canonical and robots tags alone.** Search engines read the HTML first and the rendered page later, if at all. A canonical or noindex that JavaScript changes sends two different instructions, and Google may act on either.
>
> 1. Put the final title, description, canonical and robots tags in the HTML the server sends.
> 2. In a single-page app, set them on the server for each route (Next.js metadata, Nuxt useHead with SSR, Angular Universal).
> 3. Never add noindex with JavaScript to a page you want indexed; Google may drop it before the script runs.

## Links between views

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](https://getreport.app/guides/infinite-scroll-seo) 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.

```js
// 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](https://getreport.app/guides/soft-404s-what-they-are-and-how-to-fix-them) 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](https://getreport.app/guides/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:

> **Free tool:** [JavaScript rendering checker: raw vs rendered HTML](https://getreport.app/tools/js-rendering-check): Free JavaScript rendering checker: compare raw HTML with the rendered page and see which text, links, tags and structured data appear only after scripts run.

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.

> **Check: The visible text is present without JavaScript.** Google renders JavaScript later and with a budget, so text that only appears after scripts run can be indexed late or not at all. Other search engines and link previews may never see it.
>
> 1. Serve the main content in the HTML (server-side rendering or static generation) and use JavaScript only to enhance it.
> 2. Check the difference in the technical detail; menus and widgets are fine, headlines and body copy are not.

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.
