A JavaScript page can look finished in your browser while the HTML your server actually sent contains almost nothing: an empty <div id="root"> and a script tag.
Google can usually fill that gap. It renders JavaScript. But rendering is a second step with its own rules, and those rules are where JavaScript SEO goes wrong. Whether a site uses React, Vue, or Next.js isn't the question. The question is what Google gets for each URL, and when.
How Google Processes a JavaScript Page
Two details in that pipeline cause most real-world problems:
- The raw HTML is read before renderingSome decisions, like whether a page is noindexed, can be made at that point, before any JavaScript runs.
- Rendering can happen later than crawlingMost pages render quickly, but content and links that only exist after JavaScript runs aren't available until then.
JavaScript isn't bad for SEO. Pages break when important content, links, or signals exist only after JavaScript runs, and the JavaScript fails, depends on something Googlebot doesn't have, or contradicts the raw HTML.
CSR, SSR, SSG: Where the HTML Comes From
- Client-side rendering (CSR)The server sends a shell and the browser builds the page. Everything a search engine cares about depends on
app.jsrunning and its API calls succeeding. That's a lot of dependencies for a blog post or product page. - Server-side rendering (SSR)The response already contains the heading, content, and links. JavaScript then hydrates the page, attaching interactivity. Client code can still overwrite titles, swap canonicals, or replace content after hydration.
- Static site generation (SSG)HTML is built ahead of time and served as files. It suits blog posts, docs, and marketing pages. Same caveat as SSR: client JavaScript can still change it.
- Hybrid sitesMost frameworks mix these per route: SSG for the blog, SSR for products, CSR for the account area. So ask "is this CSR?" per template, not per site.
<body>
<div id="root"></div>
<script src="/app.js"></script>
</body>
Things That Break JavaScript Pages in Google
- A noindex in the raw HTMLGoogle's documentation says that when it finds a
noindexrobots meta tag in the raw HTML, it may skip rendering entirely. "Ship noindex in the shell, remove it with JavaScript later" can keep the page out of the index. - Wrong status codes in single-page appsSPAs often return
200 OKfor every route, including ones that don't exist, while JavaScript shows "not found." Google tends to treat these as soft 404s. Its suggested fixes: redirect with JavaScript to a URL that returns a real 404, or add<meta name="robots" content="noindex">with JavaScript on error pages. - Routes built with fragmentsGoogle ignores everything after
#, so/#/products/walletsisn't a separate page. Use the History API so each view has a real path like/products/walletsthat returns content when requested directly. - Content that needs a click or a scrollGooglebot doesn't click buttons, open tabs, or scroll like a user. Native
loading="lazy"or an IntersectionObserver is fine. A "Load more" button is not. - Content that depends on stored stateGooglebot is stateless. It clears cookies, localStorage, and sessionStorage between page loads, so content gated on a saved preference or consent choice may render as an empty fallback.
- Links that aren't linksRendered links still need to be
<a href="/real-url">. Navigation built ononclickhandlers or<div>elements gives Google nothing to follow. - Metadata that changes after renderingGoogle generally uses the rendered title, but canonicals are riskier. If raw HTML has one canonical and JavaScript sets another, you're sending conflicting signals. Set it correctly in the raw HTML.
- Failed requests during renderingA timed-out API, a script blocked by robots.txt, a CORS error, or an endpoint that requires login all leave the rendered page incomplete. Your own session and cache can hide this in the browser.
- Stale cached JavaScriptGoogle caches resources aggressively. Fingerprinted file names like
app.3f9a2c.jsmake sure a new deploy is picked up as a new file.
/#/products/wallets
Google ignores everything after #
/products/wallets
A real URL that returns content directly
A Note on Dynamic Rendering
Dynamic rendering means serving pre-rendered HTML to bots and the JavaScript app to users. Google now describes it as a workaround, not a recommended solution. SSR or SSG fixes the same problem without maintaining two versions of every page.
Not Every Crawler Renders
Google's rendering is unusually capable. Many other bots, including a number of AI crawlers, appear to read only the raw HTML. If content is in the raw response, every crawler can see it. If it only exists after rendering, you're relying on each bot to run your JavaScript. We cover the differences in our guide to AI crawlers vs traditional search crawlers.
How to Test Raw vs Rendered HTML
- Look at the raw responseUse View Source (not Inspect, which shows the live DOM) or fetch the URL from the command line. Note the title, meta robots, canonical, H1, main content, internal links, and structured data.
- Look at what Google renderedIn Search Console, the URL Inspection tool shows the rendered HTML plus any resources Google couldn't load. The Rich Results Test does the same for any public URL.
- Compare the signals, not just the visualsUse the table below as your diff list.
- Request deep URLs directlyPaste a deep route into a fresh private window without clicking through from the homepage. Routes that only work after the app has loaded once are broken for crawlers.
- Test one URL per templateProduct, category, blog, and filtered listing pages share code. A problem in one usually means a problem in all of them.
| Element | What to watch for between raw and rendered |
|---|---|
| Meta robots | noindex in raw HTML that JavaScript later removes |
| Title | Placeholder or identical titles across routes in the raw HTML |
| Canonical | Missing in raw, different in rendered, or duplicated |
| H1 and main content | Present only after rendering, or missing after a failed request |
| Internal links | Only in rendered HTML, or not real <a href> elements |
| Structured data | Injected by JavaScript with values that don't match the page |
| Status code | 200 returned for routes that show "not found" |
Checking Rendered Pages at Scale
Manual checks work for a handful of URLs. For a whole site, you need a crawler that renders.
The SiteAuditLint JavaScript rendering feature crawls pages with Chromium, so titles, headings, canonicals, and links are read from the page a browser actually builds. Run the crawl with rendering and look for templates where titles repeat, H1s are missing, or link counts drop to near zero. Then run those URLs through the URL Inspection tool to confirm what Google saw.
The Short Version
CSR, SSR, and SSG are all fine choices when the URL that matters returns the right status, the right signals in the raw HTML, and the right content after rendering. Check both versions of the page, and fix whatever changes between them that you didn't intend.