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.

By , founder of Porteur · Updated 23 September 2026 · Markdown

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.

FailureWhat Google getsTest
Empty HTML shellNo text or links until the rendering passcurl the route and grep for a sentence from the page
One title for every routeEvery page titled the same, so none matches a searchcurl three routes and compare their <title>
200 on unknown routesEvery typo is a page, treated as a soft 404curl 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 hrefgrep the rendered HTML for <a href
Content loaded after an interactionText behind a click or a scroll handler is never seenCheck that the text is in the rendered HTML without clicking
Hash routes/#/pricing and /#/about are one URL to Google, which does not index fragmentsLook at your address bar: a # before the path is the fault
# 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.

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

ApproachWhat the crawler getsEffortWhen
Prerender static routes at buildA full HTML file per route, the same one users getLow: a build step and a list of routesMarketing pages, docs, pricing: routes whose content changes with a deploy
Server-side renderingFull HTML per requestMedium to high: a framework and a serverMany pages built from data, such as a catalogue or user profiles that should rank
Move marketing pages to a static siteFull HTML for the public pages, the SPA stays for the appMedium: a second siteThe app is behind a login and only a dozen public pages need to rank
Dynamic renderingRendered HTML for bots, the shell for usersMedium, plus a service to runA 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.

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.

# 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
{
  "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:

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.

<!-- 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

Check my site, free

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, no card
  • Read-only, your own accounts
  • Readable by your agent

Read next