Single-Page Application SEO: Making React, Vue, and Angular Rank

No Comments

Client-side frameworks ship a near-empty HTML shell and assemble the page in the browser with JavaScript. That model is fast for users on a warm cache and brutal for crawlers, because the content that should rank often doesn't exist until JS executes. Fixing this is less about tricks and more about choosing the right rendering strategy and disciplining your routing and link patterns.

Why SPAs Struggle to Get Indexed

Google can render JavaScript, but rendering is a deferred, resource-limited second pass. Your URL gets crawled, queued, and rendered later, sometimes days after the initial fetch. Anything that depends on that render is at the mercy of a queue you don't control. Other crawlers and most AI/LLM bots, social scrapers, and Bing in many cases either don't execute JS at all or do it inconsistently.

The failure modes that actually cost rankings:

  • Empty initial HTML. View-source shows <div id="root"></div> and nothing else. If the render pass fails or times out, there is no content to index.
  • Content gated behind interaction. Anything loaded on scroll, click, or hover is invisible to a crawler that never interacts.
  • Client-only routing. Routes that only exist in the JS router return a 200 with an empty shell for every path, so soft 404s and thin pages proliferate.
  • Slow or blocked resources. If the JS bundle is render-blocked, robots-disallowed, or too slow, the rendered DOM never reaches the indexer.

Pick a Rendering Strategy (This Is the Whole Game)

The single highest-leverage decision is where the HTML gets built. In rough order of SEO reliability:

  1. Static Site Generation (SSG), Pre-render every page to HTML at build time. The crawler gets complete content on the first fetch, no render queue involved. Best for content that doesn't change per-request: marketing pages, docs, blogs, product catalogs. Tools: Next.js (output: 'export' or static routes), Nuxt (nuxt generate), Angular's prerender builder, Astro, Gatsby.
  2. Server-Side Rendering (SSR), Build HTML on each request on the server, then hydrate in the browser. Right for personalized or frequently-changing pages. Next.js App Router, Nuxt SSR mode, and Angular Universal (@angular/ssr) all do this. Watch your TTFB; a slow server render just moves the bottleneck.
  3. Incremental Static Regeneration / hybrid, Static by default, revalidated on an interval or on-demand. Gives you SSG crawlability with fresher data for large catalogs.
  4. Dynamic rendering (prerendering for bots), Serve pre-rendered HTML to crawlers via Prerender.io, Rendertron, or a self-hosted headless Chrome. Google treats this as a legitimate workaround, not cloaking, as long as the bot and user see the same content. Treat it as a transitional fix, not a destination.

Pure client-side rendering (CRA-style, Vite SPA, Vue/Angular default builds) is the one to avoid for anything that must rank. If you're locked into it, dynamic rendering is your bridge until you can migrate to SSG or SSR.

Fix Routing So Every URL Is Real

A rankable page needs a unique, server-resolvable, crawlable URL. SPA routers break this in predictable ways.

  • Kill hash routing. URLs like example.com/#/products/42 are a problem, everything after # is dropped before the request reaches the server, so Google treats them all as one URL. Use the HTML5 History API: React Router's BrowserRouter, Vue Router's createWebHistory, Angular's default PathLocationStrategy. Configure your server to rewrite unknown paths to index.html (or, better, to your SSR/SSG output).
  • Return correct status codes. A client-only SPA returns 200 for /this-page-never-existed. With SSR/SSG, make missing routes return a real 404, and use 301 for moved URLs. Soft 404s waste crawl budget and dilute quality signals.
  • One URL per piece of content. Collapse trailing-slash, casing, and query-param duplicates. Set a self-referencing <link rel="canonical"> on every page, and make sure it's in the rendered HTML, not injected only after a user action.
  • Generate a real XML sitemap from your route manifest at build time and keep it in sync. Don't hand-maintain it.

Make Links Crawlable

Crawlers follow links by reading href attributes in anchor tags. They do not click buttons or fire JS navigation handlers.

  • Use real anchors: <a href="/products/42">. Your framework's link component (<Link>, <router-link>, routerLink) renders a proper href while keeping client-side navigation, use it.
  • Never put navigation on <div onClick> or <span> with a JS-only handler. There's no href to follow, so the destination page is undiscoverable through that path.
  • Avoid linking deep pages only through interactive components (mega-menus, "load more" buttons, infinite scroll). Provide a crawlable path, paginated URLs with real hrefs, or a category index, to everything you want indexed.

Handle Metadata and Head Tags Per Route

A common SPA bug: every route shares the one <title> and meta description baked into index.html. Each route needs its own title, description, canonical, Open Graph tags, and structured data, present in the response the crawler renders.

  • Next.js: the Metadata API or generateMetadata() per route.
  • Vue/Nuxt: useHead / useSeoMeta (Nuxt) or @unhead/vue.
  • Angular: the Title and Meta services, set in the route's resolver or component, rendered through Angular Universal.

Add JSON-LD structured data (Article, Product, BreadcrumbList, FAQPage) server-side so it's in the initial HTML. Validate the rendered output, not your source code.

Common Mistakes

  • Blocking JS or CSS in robots.txt. If Googlebot can't fetch the bundle, it can't render the page. Allow your assets.
  • Testing with view-source only. View-source shows the raw HTML; it won't reflect the rendered DOM. Use the URL Inspection tool's rendered HTML and screenshot in Search Console, and run a JS-rendering crawler.
  • Lazy-loading critical content. Code-split non-critical UI, but keep the main content and its links in the initial payload.
  • Hydration mismatches. When server HTML and client render disagree, frameworks may discard the server markup. Keep render output deterministic, no Date.now(), random IDs, or browser-only checks in the render path.
  • Injecting canonicals/titles after a click. If metadata only appears after interaction, the indexer never sees it.

How to Verify It Works

  1. Fetch the URL with JS disabled (or curl), your main content and links should be present.
  2. Run Search Console URL Inspection on live URLs and read the rendered HTML and "Page indexing" status.
  3. Crawl the site with a renderer-capable crawler (Screaming Frog in JS-rendering mode) and compare raw vs. rendered word counts and link counts.
  4. Confirm 404s return 404, redirects return 301/302, and canonicals are self-referencing in the rendered output.

Get rendering, routing, links, and per-route metadata right and a React, Vue, or Angular app indexes as cleanly as any static site. The framework was never the problem, shipping content the crawler can't see was.

Want this handled properly on your site?

It is exactly the kind of work an advanced technical SEO audit covers. See how an advanced SEO audit works →

Claude Vincent is a technical SEO consultant focused on crawlability, rendering, and AI-search visibility. He writes the field guides and case studies at SEO ProCheck, with a bias toward the durable, unglamorous work that decides whether search engines and AI answer engines can actually read and cite a site.

    About SEO ProCheck

    Technical SEO consulting and GEO strategy with 20 years of enterprise experience. Case studies, resources, and tools for search and AI visibility.

    Work With Me

    Technical SEO audits, GEO strategy, site migrations, and international SEO. Hourly consulting for teams who need hands-on support, not just reports.

    Subscribe to our newsletter!

    More from our blog