SEO for JavaScript-rendered sites
How rendering is handled, the difference between SSR, prerendering and client-side rendering, hydration and SPA routing, and how to see what the crawler actually receives.
The rendering options
| Approach | HTML on first response | Best for | Main risk |
|---|---|---|---|
| Static generation | Complete, prebuilt | Content that changes on a schedule | Rebuild latency for large sites |
| Server-side rendering | Complete, generated per request | Personalised or fast-changing content | Origin latency and cache correctness |
| Incremental static with revalidation | Complete, cached, refreshed in background | Large catalogues and content sites | Stale page served after an update |
| Prerendering | Injected for crawlers, client-rendered for users | Legacy SPAs you cannot rewrite yet | User and crawler see different pages |
| Client-side rendering only | Empty shell plus a script | Logged-in app screens | Delayed indexing and missing content |
# what does a crawler actually receive? ask as a plain client, no JS execution
curl -sL -A "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" \
https://example.com/guides/dns/ttl | head -60
# and then compare with a rendered view
# 1. open the URL in a browser with JavaScript disabled
# 2. check the rendered HTML in the browser devtools after load
# 3. compare the two - anything only in the second is at risk- If the title, description, canonical and main content are absent from the raw response, indexing depends entirely on the rendering queue and will be slower and less reliable.
- Check the
noscriptpath. A page that renders nothing without JavaScript is a page whose content an engine may never see. - Server-render metadata at minimum: title, meta description, canonical, hreflang and structured data.
SPA routing and hydration
// metadata per route: the crawler needs it in the served HTML, not set on load
// server side, before sending the response:
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(renderToString({
title: "DNS TTL and caching explained",
description: "What TTL controls at each cache layer and how to plan a cutover.",
canonical: "https://example.com/guides/dns/ttl"
}));
// client side, keep the router honest
router.afterEach((to) => {
document.title = to.meta.title;
const link = document.querySelector('link[rel="canonical"]');
if (link) link.href = to.meta.canonical;
});- Serve every route from the server with a real status code. A SPA that returns 200 for a nonexistent route makes every broken URL look like a valid page.
- Return a real 404 for unknown routes rather than a client-side empty state - otherwise the crawl fills with soft 404s.
- Hydration must produce the same tree the server rendered. A mismatch causes a re-render and can shift the main content after the crawler has read it.
- Avoid rendering the main content behind an interaction. Tabs, accordions and lazy sections that only exist after a click are not reliably indexed.
- Infinite scroll needs equivalent paginated URLs. Links that only exist after scrolling are links the crawler may never follow.
Testing the rendered output
Weekly check, five minutes
1. curl the URL without JavaScript -> is the content there?
2. Inspect with JavaScript disabled -> does the page work at all?
3. Search Console URL inspection -> does the rendered HTML match?
4. Block the script in devtools -> is a fallback or message shown?
5. Check the canonical in both views -> same URL, no client-side override| Symptom | Likely cause | Fix |
|---|---|---|
| Content indexed only after weeks | Client-side rendering with no SSR | Server-render the content or prerender |
| Title shows the site name on every page | Title set only after hydration | Emit the title in the served HTML |
| Duplicate pages from query routes | No canonical per route | Render the canonical server-side |
| Soft 404s in coverage reports | SPA returns 200 for missing routes | Return 404 for unknown routes |
| Internal links not followed | Links built on click, not as anchors | Render real anchor elements with href |
⚠️
Injection or prerendering that serves different content to a crawler than to a user is cloaking, even when the intent is innocent. If the prerendered page and the user's page differ in substance, you are relying on the crawler never comparing them - which is not a durable position.
FAQ
Is client-side rendering still a problem?
Rendering is handled for most sites now, but it is queued and slower, and anything that requires interaction to appear is unreliable. Server-rendering the content and metadata removes the whole class of problem.
Do I need a prerender service?
Only as a stopgap for a legacy SPA. It adds a component that can serve stale or inconsistent pages. Migrating to server rendering is the durable fix.
Related
Technical SEO and measuring it Page speed and Core Web Vitals optimisation
Last refreshed 2026-09-18.