---
title: "JavaScript SEO: SPAs, SSR and prerendering"
description: "The core problem A classic server-rendered page sends complete HTML: content, links and meta tags are all in the first response. A client-side rendered…"
url: https://optimizeall.com/learn/technical-seo-mastery/javascript-seo-spas-ssr-prerendering
updated: 2026-10-05
---

Technical SEO Mastery · How search engines crawl, render and index · lesson 2 of 18 · 15 min

# JavaScript SEO: SPAs, SSR and prerendering

## The core problem

A classic server-rendered page sends complete HTML: content, links and meta tags are all in the first response. A client-side rendered **single-page application (SPA)** often sends an almost empty shell:

```html
<body>
  <div id="root"></div>
  <script src="/static/js/main.4f2a.js"></script>
</body>
```

Everything a search engine needs — text, internal links, titles, canonicals — only exists after JavaScript runs. Google can render JavaScript, but you add risk and delay at every step, and many other crawlers (including several AI crawlers and social preview bots) do not execute JavaScript at all.

## Rendering strategies compared

| Strategy | How it works | SEO risk |
|---|---|---|
| Client-side rendering (CSR) | Browser builds page from JS | Highest: content and links depend on successful rendering |
| Server-side rendering (SSR) | Server returns full HTML per request, then hydrates | Low, if hydration does not change content |
| Static site generation (SSG) | HTML built at deploy time | Lowest; great for content that changes infrequently |
| Incremental/on-demand regeneration | Static pages rebuilt on a schedule or trigger | Low; watch for stale content |
| Dynamic rendering | Serve prerendered HTML to bots, CSR to users | Google calls this a *workaround*, not a long-term solution |

Frameworks such as Next.js, Nuxt, SvelteKit, Remix and Astro make SSR or SSG the default. For most sites the recommendation is simple: **send meaningful HTML in the initial response** for anything you want indexed.

## The non-negotiables for JavaScript sites

1. **Real links.** Google discovers links from `a` elements with an `href`. Router links that render as proper anchors are fine; click handlers on `div` or `span` are not.

```html
<!-- Crawlable -->
<a href="/pricing">Pricing</a>
<!-- Not reliably crawlable -->
<span onclick="router.push('/pricing')">Pricing</span>
<a href="javascript:void(0)" onclick="goTo('pricing')">Pricing</a>
```

2. **Real URLs, not fragments.** Use the History API (`/products/red-shoes`), not hash routing (`/#/products/red-shoes`). Google generally ignores everything after `#`.
3. **Correct status codes.** An SPA that shows "Product not found" but returns 200 creates soft 404s. Either route unknown paths to a server-generated 404, or add a `noindex` robots meta tag via JavaScript on error views (Google documents both approaches).
4. **Meta tags in the initial HTML where possible.** Titles, canonicals and robots meta can be set with JavaScript and Google usually honours them after rendering, but conflicting values between raw and rendered HTML cause unpredictable results. Google's December 2025 documentation updates are explicit: when Google encounters `noindex` it **may skip rendering**, so never put `noindex` in the raw HTML and remove it with JavaScript; and pages returning **non-200** status codes may not be rendered, so don't rely on JavaScript on error pages to change what Google indexes.
5. **Do not block resources.** Allow crawling of JS, CSS and API endpoints the page needs to render.
6. **Lazy-load safely.** Use native `loading="lazy"` or IntersectionObserver; do not require scroll events to load primary content.

## Hydration mismatches

With SSR, the server HTML is "hydrated" by client JS. If the client renders different content (a different price, or different canonical because of a locale cookie), Google may index whichever version it saw. Audit by comparing raw HTML with rendered HTML.

## How to audit a JavaScript site

1. **Compare raw vs rendered.** Crawl twice with a desktop crawler (Screaming Frog, Sitebulb or similar): once text-only, once with JavaScript rendering. Diff word counts, links, titles and canonicals. Big differences mean you depend on rendering.
2. **Inspect with Google.** URL Inspection → Test live URL → View tested page → HTML tab. Search the rendered HTML for a unique sentence from your main content.
3. **Check the console and resources.** The "More info" tab lists page resources that could not be loaded and JavaScript console messages.
4. **Test without JavaScript.** Disable JS in Chrome DevTools. What remains is roughly what non-rendering crawlers see.
5. **Check internal link discovery.** In your rendered crawl, compare the number of unique internal URLs found against the text-only crawl.

## Worked example 1: a Dubai React catalogue

An e-commerce brand in Dubai rebuilt its catalogue as a React SPA. Category pages returned only the shell; products loaded from `/api/products`, which was disallowed in robots.txt "to save crawl budget". The rendered page in URL Inspection showed an empty grid, so Google saw thin category pages with no product links. The fix: allow the API path, switch category and product templates to SSR, output product links as real anchors, and return 404 for discontinued products. Illustratively, a fix like this typically shows up first as more pages moving into "Indexed" in the Page indexing report over the following weeks — measure it there rather than assuming.

## Hands-on: diff raw vs rendered HTML with Playwright (Python)

```python
# pip install requests playwright beautifulsoup4 && playwright install chromium
import requests
from bs4 import BeautifulSoup
from playwright.sync_api import sync_playwright

URL = "https://www.example.com/collections/trainers/"
UA = ("Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) "
      "Chrome/126.0 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)")

def summarise(html):
    s = BeautifulSoup(html, "html.parser")
    canon = s.find("link", rel="canonical")
    robots = s.find("meta", attrs={"name": "robots"})
    return {"title": s.title.string.strip() if s.title and s.title.string else None,
            "canonical": canon.get("href") if canon else None,
            "robots": robots.get("content") if robots else None,
            "links": len({a["href"] for a in s.find_all("a", href=True)}),
            "words": len(s.get_text(" ", strip=True).split())}

raw = requests.get(URL, headers={"User-Agent": UA}, timeout=20)
with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page(user_agent=UA)
    page.goto(URL, wait_until="networkidle", timeout=45000)
    rendered = page.content()
    browser.close()

a, b = summarise(raw.text), summarise(rendered)
for k in a:
    flag = "" if a[k] == b[k] else "   <-- differs"
    print(f"{k:10} raw={a[k]!s:45} rendered={b[k]!s:45}{flag}")
```

Differences in `canonical` or `robots` between raw and rendered HTML are high-severity; large differences in `links` or `words` mean the page depends on rendering for discovery or content. Playwright is not Google's renderer — confirm important findings with URL Inspection.

## Worked example 2: a Lahore SaaS on a framework "that does SSR"

A Lahore SaaS company (illustrative) says its Next.js marketing site is server-rendered, so JavaScript SEO "isn't a concern". The diff shows the raw HTML of pricing pages contains only a loading skeleton: the pricing component fetches plans client-side after hydration. The fix is fetching plan data at build or request time so prices are in the server HTML. A second finding: the locale switcher changes the canonical client-side based on a cookie, producing raw/rendered canonical mismatches — fixed by rendering the canonical on the server from the URL path only.

## Common mistakes

- Testing only in a browser where you are logged in or have cookies set.
- Relying on dynamic rendering indefinitely, and letting the bot version drift from the user version (which can look like cloaking).
- Infinite scroll with no paginated URLs.
- Forgetting that social, many AI and some search crawlers do not run JavaScript.

## Video lecture: JavaScript SEO: SPAs, SSR and prerendering

Lecture coming soon · 12 chapters · about 8 minutes. Read the full transcript below.

1. JavaScript SEO
2. Why it matters
3. Rendering strategies
4. Non-negotiables 1–4
5. Non-negotiables 5–6
6. Hydration mismatches
7. Hands-on: raw vs rendered diff
8. Example 1: Dubai React catalogue (illustrative)
9. Example 2: 'our framework does SSR' (illustrative)
10. Watch me do it: auditing a JS template
11. Common mistakes
12. Recap and try this now

## Lecture transcript

### JavaScript SEO

Here's a question that saves hours of debugging. What does the very first HTML response of your page contain? Not what you see in the browser after everything loads. The first response. In this lecture you'll learn why that question matters so much, how rendering strategies compare, the non-negotiables for JavaScript sites, what Google clarified in late twenty twenty-five, and how to diff raw and rendered HTML yourself with a short Python script.

### Why it matters

On a classic server-rendered site, the first response already contains the text, the links and the meta tags. On a client-side single-page app, it can be an empty div and a script tag. Everything search engines need only exists after JavaScript runs. Google can render JavaScript, but rendering is an extra step with extra ways to fail. And many other crawlers, including social preview bots and several AI crawlers, don't run JavaScript at all. Think of it as sending flat-pack furniture. Google will assemble it. Many others will just look at the box.

### Rendering strategies

Let's compare strategies. Client-side rendering builds the page in the browser, so it carries the highest SEO risk. Server-side rendering returns full HTML per request and then hydrates, which is low risk if hydration doesn't change the content. Static site generation builds HTML at deploy time, the lowest risk, great for content that changes infrequently. Incremental regeneration rebuilds static pages on a schedule or trigger. And dynamic rendering, serving prerendered HTML to bots and the app to users, is something Google calls a workaround, not a long-term solution.

### Non-negotiables 1–4

Now the non-negotiables. One: real links. Google discovers links from anchor elements with an href. A span with an onclick handler isn't a link. Two: real URLs, not hash fragments. Use paths like slash products slash red-shoes; Google generally ignores everything after the hash. Three: correct status codes. An app that shows product not found but returns two hundred creates soft four-oh-fours; route unknown paths to a real four-oh-four from the server. Four: don't block the JavaScript, CSS or API endpoints your pages need to render.

### Non-negotiables 5–6

Five: put meta tags in the initial HTML where you can. Google usually honours titles, canonicals and robots meta set by JavaScript after rendering, but conflicting values between raw and rendered HTML make results unpredictable. And here's what Google made explicit in December twenty twenty-five. When it sees noindex, it may skip rendering, so a noindex in the raw HTML that JavaScript later removes may never be seen as removed. And pages with non-two-hundred status codes may not be rendered at all. Six: lazy-load safely, with native loading equals lazy or intersection observers, never scroll events for primary content.

### Hydration mismatches

Hydration mismatches deserve a moment. With server-side rendering, the server sends HTML, and client JavaScript then hydrates it. If the client renders something different, say a different price, or a different canonical because of a locale cookie, Google may index whichever version it saw. The only way to catch this is comparing raw HTML with rendered HTML. Which brings us to the hands-on.

### Hands-on: raw vs rendered diff

The lesson text has a Python script that fetches a URL twice. Once with requests, which gives you the raw HTML. And once with Playwright's headless Chromium, which gives you the rendered HTML. It summarises both: title, canonical, robots meta, number of unique links and word count, and flags any differences. A different canonical or robots value is high severity. Big differences in links or words mean the page depends on rendering. One caveat: Playwright isn't Google's renderer, so confirm important findings with URL Inspection's rendered HTML.

### Example 1: Dubai React catalogue (illustrative)

Worked example one, with illustrative details. A Dubai e-commerce brand rebuilt its catalogue as a React single-page app. Category pages returned only the shell, and products loaded from an API path that was disallowed in robots.txt to save crawl budget. URL Inspection showed an empty grid, so Google saw thin category pages with no product links. The fix: allow the API path, move category and product templates to server-side rendering, output product links as real anchors, and return four-oh-four for discontinued products. Then watch the Page indexing report over the following weeks.

### Example 2: 'our framework does SSR' (illustrative)

Worked example two, also illustrative. A Lahore SaaS company says its Next.js marketing site is server-rendered, so JavaScript SEO isn't a concern. The diff shows the raw HTML of the pricing page contains only a loading skeleton, because the pricing component fetches plans on the client after hydration. The fix is fetching plan data at build or request time. And a second finding: the locale switcher changes the canonical on the client based on a cookie. The fix is rendering the canonical on the server from the URL path only. A framework that can do SSR doesn't mean every component does.

### Watch me do it: auditing a JS template

Watch me do it. I'll audit a JavaScript category template in fifteen minutes. Step one: I pick one category URL and run the Playwright diff script from the lesson. The output shows title identical, robots identical, canonical identical. But links: raw thirty-eight, rendered two hundred and twelve. And words: raw one hundred and ninety, rendered one thousand four hundred. So this page depends on rendering for its product grid. Step two: I open Search Console URL Inspection, test live, and view the tested page's HTML. I search for the name of the twentieth product in the grid. It's there. So Google can render it today, which is good, but it's a dependency. Step three: I open the More info tab. One resource failed to load: the reviews script, blocked by robots.txt on a subdomain. Not critical, but noted. Step four: I disable JavaScript in Chrome DevTools and reload. The grid disappears. That's roughly what non-rendering crawlers see, including several AI crawlers. Step five: I write the ticket: render the first page of products on the server, as real anchor links, and keep client-side filtering for later pages. And I add the diff to our release checks.

### Common mistakes

Common mistakes. Testing only in a browser where you're logged in or have cookies. Relying on dynamic rendering indefinitely and letting the bot version drift from the user version, which can look like cloaking. Infinite scroll with no paginated URLs. And forgetting that social, many AI and some search crawlers don't run JavaScript at all. How do you measure success? The share of key templates where title, canonical, robots and main content match between raw and rendered HTML.

### Recap and try this now

Recap. For anything you want indexed, send meaningful HTML in the first response. Use real links, real URLs and real status codes. Don't block resources. Keep noindex and canonicals consistent from the start, because noindex can stop rendering and non-two-hundred pages may not render. Try this now. Run the diff script on one URL from each of your main templates, and list every row marked differs. Each one is either a fix or a documented decision.

## Video transcript

Here's a question that saves hours of debugging: what does the very first HTML response of your page contain? Not what you see in the browser. The first response. On a classic website, that response already holds the text, the links and the meta tags. On a client-side single-page app, it can be an empty div and a script tag. Google can render JavaScript using an up-to-date version of Chromium, but rendering is an extra step with extra ways to fail. Blocked scripts, API calls that time out, content that only appears after a click. And many other crawlers, including social preview bots and several AI crawlers, don't run JavaScript at all. So the principle is simple. For anything you want indexed, send meaningful HTML in the first response. Server-side rendering and static generation, which modern frameworks make easy, solve most problems. Then check the non-negotiables. Links must be real anchor elements with an href. URLs must be real paths, not hash fragments. Unknown pages must return a real 404, or carry a noindex, so you don't create soft 404s. And never block the JavaScript or API endpoints your pages need. To audit, crawl the site twice: once without rendering and once with it. Compare word counts, links and canonicals. Then use URL Inspection in Search Console to view the rendered HTML Google actually saw. If your main content isn't in there, neither is your ranking potential.

## Key takeaways

- Send meaningful HTML (content, links, meta tags) in the initial response for anything you want indexed.
- Use real anchor links and History API URLs; avoid hash routing and click-only navigation.
- SPAs must avoid soft 404s by returning real 404s or adding noindex on error views.
- Audit JavaScript sites by diffing raw and rendered crawls and checking URL Inspection's rendered HTML.

## Try it

Crawl one JavaScript-heavy template twice (text-only and rendered) and write down the differences in word count, internal links and meta tags.

- [Previous: The crawl, render and index pipeline](https://optimizeall.com/learn/technical-seo-mastery/the-crawl-render-index-pipeline)
- [Next: Crawl budget and log-file analysis](https://optimizeall.com/learn/technical-seo-mastery/crawl-budget-and-log-file-analysis)
- [All lessons of Technical SEO Mastery](https://optimizeall.com/learn/technical-seo-mastery)
