# Single page application SEO: rendering, routes and status codes that Google can read

A single page application is not bad for SEO because of its framework; it is bad when the first HTML response holds nothing. Google renders JavaScript in a delayed second pass and most AI crawlers never do, so the fix is to put each public route's words, title and links in the HTML the server sends, and to return a real 404 for routes that do not exist. The app behind the login can stay exactly as it is.

Updated 2026-09-23 · Source: https://porteur.ai/guides/single-page-application-seo

## Are single page applications bad for SEO

The honest answer: the architecture is fine, the default output is not. An SPA built with a client-side bundler ships one index.html for every route. That file holds a root element and a script. Everything a reader sees is written by the script after it loads.

Google crawls that file, then queues the page for rendering with an evergreen Chromium. The rendering can be deferred. Until it happens, the page has no text and no links. Most AI crawlers do not execute JavaScript, so for them the page stays empty.

Nothing here is specific to one framework. A React, Vue, Svelte or Angular app has the same problem when it renders only in the browser, and none of them has it when the HTML is produced earlier. The general mechanics are covered in the guide on JavaScript rendering; this page is about the faults that are particular to single page apps and the fixes for each.

## The six failure points, and a test for each

Run these tests on three routes: the home page, the pricing page and one deep content page. Each takes under a minute.

| Failure | What Google gets | Test |
| --- | --- | --- |
| Empty HTML shell | No text or links until the rendering pass | curl the route and grep for a sentence from the page |
| One title for every route | Every page titled the same, so none matches a search | curl three routes and compare their <title> |
| 200 on unknown routes | Every typo is a page, treated as a soft 404 | curl a made-up path and print the status code |
| Links that are not <a href> | No path to the next page; Google follows links only in <a> elements with an href | grep the rendered HTML for <a href |
| Content loaded after an interaction | Text behind a click or a scroll handler is never seen | Check that the text is in the rendered HTML without clicking |
| Hash routes | /#/pricing and /#/about are one URL to Google, which does not index fragments | Look at your address bar: a # before the path is the fault |

```bash
# 1. Is the text in the first response?
curl -s https://yourproduct.com/pricing | grep -c 'a sentence from your pricing page'

# 2. Does each route have its own title?
for path in / /pricing /features; do
  printf '%s  ' "$path"
  curl -s "https://yourproduct.com$path" | grep -o '<title>[^<]*</title>'
done

# 3. Does an unknown route return 404?
curl -s -o /dev/null -w '%{http_code}\n' https://yourproduct.com/no-such-page

# 4. Are there real links in the HTML?
curl -s https://yourproduct.com/ | grep -o '<a [^>]*href="/[^"]*"' | sort -u | head -20
```

A count of 0 on the first test, the same title three times, a 200 on the third or an empty list on the fourth each names a fix below. On a client-rendered app you will often get all four.

## Hash routes and the History API

Older SPAs route with the fragment: yourproduct.com/#/pricing. The part after # never reaches the server and Google does not index it, so the whole site is one URL. Google's guidance for SPAs is to use the History API, which gives each route a real path.

Every current router supports it; it is usually one option. In React Router, use createBrowserRouter instead of createHashRouter. In Vue Router, createWebHistory instead of createWebHashHistory. Once the paths are real, the server must answer them, which is what the host rules below do.

```ts
// Before: one URL to Google
const router = createHashRouter(routes);

// After: /pricing is a real path
const router = createBrowserRouter(routes);
```

Keep the old fragment links working for people who bookmarked them with a small script on the home page that reads location.hash and replaces it with the matching path.

## The fixes, ranked by effort

Google documents dynamic rendering, serving prerendered HTML only to bots, as a workaround and not a recommended long-term solution. It recommends server-side rendering, static rendering or hydration instead. That sets the order.

| Approach | What the crawler gets | Effort | When |
| --- | --- | --- | --- |
| Prerender static routes at build | A full HTML file per route, the same one users get | Low: a build step and a list of routes | Marketing pages, docs, pricing: routes whose content changes with a deploy |
| Server-side rendering | Full HTML per request | Medium to high: a framework and a server | Many pages built from data, such as a catalogue or user profiles that should rank |
| Move marketing pages to a static site | Full HTML for the public pages, the SPA stays for the app | Medium: a second site | The app is behind a login and only a dozen public pages need to rank |
| Dynamic rendering | Rendered HTML for bots, the shell for users | Medium, plus a service to run | A stopgap while you migrate, never the plan |

For most founders the first row is enough. A marketing site of twenty routes does not change between deploys, so generating each page at build costs nothing at runtime and gives every crawler the full text.

> Whatever you pick, bots and users must get the same content. Rendering the same page earlier is fine. Showing bots text that users never see is cloaking.

## Host rules that return a real 404

The usual SPA hosting setup rewrites every path to index.html with status 200. That is what makes deep links work, and it is also why every typo answers 200. Google's guidance for unknown routes offers three ways out: return a 404 from the server, add a noindex robots meta tag, or redirect to a URL that returns 404.

The cleanest is to rewrite only the routes you know and let everything else fall through to a 404. Here is the same rule for three common setups. Replace the route list with yours.

```text
# Netlify: public/_redirects (first match wins)
/                /index.html   200
/pricing         /index.html   200
/features        /index.html   200
/about           /index.html   200
/blog            /index.html   200
/blog/*          /index.html   200
/*               /404.html     404
```

```json
{
  "rewrites": [
    { "source": "/pricing", "destination": "/index.html" },
    { "source": "/features", "destination": "/index.html" },
    { "source": "/about", "destination": "/index.html" },
    { "source": "/blog", "destination": "/index.html" },
    { "source": "/blog/:slug", "destination": "/index.html" }
  ]
}
```

That second block is a vercel.json. Paths that match no file and no rewrite get Vercel's 404 status, and a 404.html in the output is used as the page. On your own server with nginx:

```nginx
server {
  root /var/www/yourproduct;
  error_page 404 /404.html;

  # Known app routes get the SPA shell with status 200
  location ~ ^/(pricing|features|about|blog)(/[a-z0-9-]+)?/?$ {
    try_files $uri /index.html;
  }
  location = / {
    try_files /index.html =404;
  }

  # Everything else: a real file, or a real 404
  location / {
    try_files $uri =404;
  }
}
```

Dynamic routes such as /blog/:slug still answer 200 for a slug that does not exist. Cover that inside the app: when the data lookup fails, render the not-found view with a noindex tag, as below.

```html
<!-- Rendered by the not-found view only -->
<meta name="robots" content="noindex">
```

## How to verify the fix

1. **View source, not the inspector** Open view-source: on each public route. The inspector shows the DOM after scripts; view source shows the first response, which is what every crawler reads first.
2. **Run the curl tests again** All four should now pass: text present, titles differ, the made-up path returns 404, links listed.
3. **Run URL Inspection's live test** In Search Console, test the live URL and open the rendered HTML. Search it for your text. Check the page resources list for scripts blocked by robots.txt, since blocked resources cannot be rendered.
4. **Watch the Pages report for a month** Soft 404s and "crawled, currently not indexed" should fall as Google recrawls. Request indexing on the main routes to speed up the first look.

Keep the curl block in your repository and run it after every deploy that touches routing. A rewrite rule edited months later is the usual way a fixed site breaks again.

## The per-route checklist

- A real path, no # in it.
- The page's main text in the first HTML response.
- Its own <title> and meta description, set per route.
- One canonical pointing at its own URL.
- Links to other pages as <a href="/path">.
- Listed in the sitemap if it should be found; absent if it sits behind the login.
- Unknown paths and missing records answer 404 or carry noindex.

## Questions

### Can Google index a single page application?

Yes. Google renders JavaScript with an evergreen Chromium, so a client-rendered SPA can be indexed. The rendering happens in a later pass that can be deferred, and it fails quietly when a script errors or a resource is blocked, so indexing is slower and less certain than with HTML the server sends.

### Is server-side rendering required for SPA SEO?

No. For pages whose content only changes when you deploy, prerendering them at build gives crawlers the same full HTML with no server to run. Server rendering earns its cost when you have many pages built from data that changes between deploys.

### Is dynamic rendering still a good idea?

Google calls it a workaround and not a long-term solution. It also means running two versions of every page and keeping them identical. Use it only to bridge a migration to static or server rendering.

### Do AI crawlers see my SPA?

Most AI crawlers do not execute JavaScript. They read the first HTML response, so a client-rendered page is empty to them. Prerendering or server rendering is the only way into their answers.

### Should the dashboard behind the login be rendered on the server too?

No. Pages behind a login cannot be crawled anyway, so client rendering is fine there. Keep them out of the sitemap and put the effort into the public routes.

## Read next

- [JavaScript rendering: what Google sees, what the AI crawlers do not](https://porteur.ai/guides/javascript-rendering-and-seo): Google renders JavaScript in a second pass and the AI crawlers do not render it at all. Here is what breaks, how to test it, and what to change.
- [React SEO: making a React app readable by Google and by AI crawlers](https://porteur.ai/guides/react-seo): Is React bad for SEO? How to tell what your React app sends crawlers, set per-route titles in React 19, prerender routes and avoid the React traps.
- [Prerendering](https://porteur.ai/glossary/prerendering): Prerendering means producing a page's HTML before the request. How it differs from SSR and dynamic rendering, how to test it, and where it becomes cloaking.
- [Lovable SEO: why older Lovable sites were not indexed, and what to check now](https://porteur.ai/guides/lovable-seo): Is Lovable good for SEO? It depends on the project's age. How to tell which you have, test what crawlers get, and the checklist still yours to do.
- [Soft 404: when a page says 200 and Google reads not found](https://porteur.ai/guides/soft-404): Soft 404 means your server says 200 but the page reads like an error or empty. See where it shows in Search Console and fix each case fast.
- [How to use the URL Inspection tool in Search Console](https://porteur.ai/guides/url-inspection-tool): Read each panel, run Test live URL, and know when to request indexing. Fix new pages, dropped pages, and canonicals Google ignores.
- [Cloaking](https://porteur.ai/glossary/cloaking): Cloaking is showing Google different content than users. See what counts, what is safe, how to check your site, and how to fix risky setups.
- [Next.js SEO: what the framework does for you and what it does not](https://porteur.ai/guides/nextjs-seo): What Next.js gives you for SEO and what still needs your work: metadata, sitemap, robots, rendering, redirects, URLs, images, fonts and links.

Paste your app's URL into the free check: in about thirty seconds it reads your site, the searches around it and the rivals on them, and shows three findings whole. Free check: https://porteur.ai/
