JavaScript doesn't hurt your SEO by itself. Google runs it, even for titles and canonicals. The trouble starts when important content exists only after rendering, when the server answers 200 for every URL, or when links have no real address. There's a second point: not every bot runs JavaScript. You can find out whether you have a problem in five minutes by comparing the page source with the rendered page.
This guide is for SEOs and developers. Diagnosis comes first, then eight failure patterns with code, then Next.js, Nuxt, Vue, React without a framework, and Astro. We read the documentation on 5 November 2026: Next.js 16.4, Nuxt 4.6, React 19.3. The code examples ran the same day with Next.js 16.4.3, Nuxt 4.6.2, React 19.3.0 and Astro 7.4.2.
SSR vs CSR: which rendering carries which risk
The question that decides it: is the content in the HTML the server sends? The "SEO risk" column is our judgement, not a measurement.
| Type | Where the HTML is built | SEO risk | Suits |
|---|---|---|---|
| CSR (client-side rendering) | In the browser, the server sends an empty shell | Highest. Content, links and status all depend on JavaScript | Logged-in areas, tools |
| SSR (server-side rendering) | On the server, per request | Low, as long as the server answers fast and error codes are right | Pages that change often or per user |
| SSG (static site generation) | At build time, as a finished file | Low. Freshness depends on the next build | Guides, documentation, landing pages |
| ISR (incremental static regeneration) | Static, rebuilt after a time or an event | Low. When debugging, check which version is being served | Large catalogues |
Google itself says server-side or pre-rendering is still a great idea, because it makes your site faster for users and crawlers and not all bots can run JavaScript. The older approach of pre-rendering only for bots, dynamic rendering, is described by Google as a workaround and not a long-term solution.
How Google processes JavaScript
The basics are in Google's documentation, last updated on 4 March 2026. Many older texts, including the earlier version of this article, talk about a "second wave" with a wait of days or weeks. Google says something different today: a page may stay in the queue for a few seconds, but it can take longer than that. It gives no reliable duration, long or short.
The process has three phases:
- Crawling. Googlebot checks robots.txt and requests the URL. It reads links from the
hrefattributes in the HTML response. - Rendering. All pages with a 200 status go into the queue, whether they contain JavaScript or not, unless a robots meta tag or header rules out indexing. A headless Chromium runs the JavaScript once resources allow.
- Indexing. Google uses the rendered HTML, and links found there go into the crawl queue.
A few things follow from this that matter in practice:
- Non-200 status: rendering might be skipped, so a 404 has to come from the server.
- noindex in the original HTML: Google may skip rendering and JavaScript, so removing it later with a script may not work.
- No memory: each URL loads without state. Local Storage, Session Storage and cookies are cleared across page loads.
- No permissions: Googlebot declines permission requests, for example for the camera.
- HTTP only: it doesn't support WebSockets or WebRTC. Content that arrives only that way isn't seen.
- Caching: Googlebot caches aggressively and may ignore caching headers. That's why Google recommends file names with a content fingerprint, such as
main.2bb85551.js. - Titles and canonicals through JavaScript: both are possible, though Google prefers the canonical in the HTML. JavaScript shouldn't change the canonical to anything other than the value in the original HTML.
AI crawlers: what can and can't be shown
The claim "they don't run JavaScript" circulates widely, usually without a source. On 5 November 2026 we read the crawler pages from OpenAI, Anthropic and Perplexity. They cover crawler names, IP ranges and robots.txt. On rendering JavaScript they say nothing, for or against.
Two neighbouring statements are documented. Google says in general terms that not all bots can run JavaScript. And the Next.js documentation treats "HTML-limited bots" as a separate group, naming facebookexternalhit as an example.
That's enough for practical purposes, because the advice doesn't depend on the open question: if your content is in the server's HTML, you don't need to know what any bot can do. To find out for your own site, check your server logs for crawlers and for requests to your JavaScript files, and allow for three traps. Bots cache files, user agents can be faked, and few requests prove nothing. Check against the IP ranges the vendors publish, and read zero requests as a hint and not as proof.
Diagnosis in five minutes
This order costs nothing and answers most questions.
- View the source. Use
Ctrl+Uorcurl. Look for a sentence from the main content, the title and an important internal link. - View the rendered version. URL Inspection in Search Console shows the rendered code, loaded resources and console output. For other people's pages, use the Rich Results Test.
- Compare them. If the sentence appears only in the rendered version, it depends on JavaScript.
- Check the status. Request a URL that doesn't exist. If you get 200 and not 404, you have a soft 404 problem.
- Turn JavaScript off. Whatever disappears is also missing for a bot that doesn't run JavaScript.
#!/bin/sh
# Quick check: look for a phrase in the server's HTML
URL="$1"
PHRASE="$2"
curl -s -o /dev/null -w 'Status: %{http_code}\n' -A "Mozilla/5.0" "$URL"
curl -s -A "Mozilla/5.0" "$URL" | grep -o -F "$PHRASE" | wc -l
curl -s -A "Mozilla/5.0" "$URL" | grep -i -o '<meta[^>]*name="robots"[^>]*>'
curl -s -A "Mozilla/5.0" "$URL" | grep -i -o '<link[^>]*rel="canonical"[^>]*>'
| Symptom | Likely cause | How to check |
|---|---|---|
| Page indexed, part of the text missing from the rendered HTML | Content loads only after interaction or over WebSocket | URL Inspection, rendered HTML |
| "Crawled, not indexed" on many similar pages | Empty app shell, every page looks alike | Compare the source of several URLs |
| Soft 404 in Search Console | Error page with status 200 | curl an invented URL |
| New pages aren't found | Links without href, or hash routes | Check links in the rendered HTML |
| Content missing, though fine in your browser | JavaScript file blocked in robots.txt, or state expected from a cookie or Local Storage | Loaded resources in URL Inspection |
| Google shows old content or errors after a deploy | Old JavaScript file in cache | Fingerprinted file names? |
| Unexpected canonical | JavaScript changes it, or there are several | Source against rendered HTML |
The table builds on Google's troubleshooting list. Matching symptom to cause is our reading. It shows where to look first and doesn't prove the cause.
Eight failure patterns, with check and fix
This isn't a ranking, and we haven't measured how often each occurs. Other items on Google's list that we don't show one by one: lazy loading has to be built so Google can find the images, structured data can be injected with JavaScript and then needs testing, and with web components Google sees only what's visible in the rendered HTML.
1. Content appears only after a fetch in the browser
Symptom: the source says "Loading…". Check: look for a sentence of main content in the source. Fix: fetch the data on the server.
'use client'
import { useEffect, useState } from 'react'
export default function Product({ slug }) {
const [product, setProduct] = useState(null)
useEffect(() => {
fetch(`/api/products/${slug}`).then((r) => r.json()).then(setProduct)
}, [slug])
if (!product) return <p>Loading…</p>
return <h1>{product.name}</h1>
}
Google can render this, but you're relying on the queue, on your API and on the bot running JavaScript. A server component, shown under Next.js below, removes all three.
2. Title and description missing from the head
Symptom: every page has the same title in the source, or none. Check: look for <title inside the head. Fix: generate metadata on the server. Google reads titles set with JavaScript, but social networks and other bots may not.
#!/bin/sh
URL="$1"
rm -f page.html
curl -sf -A "Mozilla/5.0" "$URL" -o page.html || { echo "Request failed"; exit 1; }
# only the part up to the first </head>
tr '\n' ' ' < page.html | sed 's#</head>.*##' > page_head.txt
grep -o -i '<title[ >]' page_head.txt | wc -l
grep -o -i 'name="description"' page_head.txt | wc -l
grep -o -i '<title[ >]' page.html | wc -l
The first two numbers count inside the head, the third across the whole document. Zero above and 1 below means the title is in the body.
3. JavaScript changes the canonical
Symptom: the canonical in the source differs from the rendered one. Check: compare both versions and count the tags. Fix: set it in the HTML, once. Multiple or conflicting canonicals may lead to unexpected results, Google says.
<link rel="canonical" href="https://example.com/products/red-shoe">
4. Links with no real address
Symptom: subpages aren't found, though navigation works in the browser. Check: are they <a> elements with href? Fix: Google can generally only crawl links built that way. An extra onclick does no harm.
<!-- Google can follow these -->
<a href="/products/shoes">Shoes</a>
<a href="/products/shoes" onclick="goTo('shoes')">Shoes</a>
<!-- Google can't reliably follow these -->
<a onclick="goTo('shoes')">Shoes</a>
<span onclick="goTo('shoes')">Shoes</span>
5. Routes behind a hash
Symptom: addresses like /#/products. Check: the address bar. Fix: Google can't reliably resolve these and recommends the History API.
<!-- Bad -->
<a href="#/products">Products</a>
<!-- Good -->
<a href="/products">Products</a>
<script>
document.querySelector('a[href="/products"]').addEventListener('click', (e) => {
e.preventDefault()
history.pushState({}, '', '/products')
// load the view here
})
</script>
For these URLs to work when opened directly, the server has to know them too.
6. Error page with status 200
Symptom: invented URLs return 200. Check: curl -I on an invented address. Fix: the right answer is a 404 from the server. In a pure SPA, Google names two ways out:
fetch(`/api/products/${id}`)
.then((r) => r.json())
.then((product) => {
if (!product.exists) {
// Option 1: go to a URL the server answers with 404
window.location.href = '/not-found'
// Option 2, instead: mark the page noindex
// const m = document.createElement('meta')
// m.name = 'robots'
// m.content = 'noindex'
// document.head.appendChild(m)
}
})
7. A noindex that JavaScript is meant to remove
Symptom: the page stays out of the index, though the rendered HTML looks clean. Check: the robots meta tag in the source. Fix: if the page should be indexed, don't put noindex in the original HTML. Google may otherwise skip rendering.
<!-- Risky -->
<meta name="robots" content="noindex">
<script>
document.querySelector('meta[name="robots"]').remove()
</script>
8. Blocked JavaScript files
Symptom: URL Inspection reports resources that didn't load. Check: robots.txt and the resource list. Fix: Google won't render JavaScript from blocked files.
# Bad
User-agent: *
Disallow: /_next/
Disallow: /assets/js/
# Better
User-agent: *
Disallow: /admin/
Next.js SEO with the App Router
In the Next.js 16 documentation, metadata is set through metadata or generateMetadata. notFound() renders a 404 page and, according to the docs, injects a noindex.
// app/products/[slug]/page.tsx
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getProduct } from '@/lib/products'
type Props = { params: Promise<{ slug: string }> }
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
const product = await getProduct(slug)
if (!product) return {}
return {
title: product.name,
description: product.summary,
alternates: { canonical: `/products/${slug}` },
}
}
export default async function Page({ params }: Props) {
const { slug } = await params
const product = await getProduct(slug)
if (!product) notFound()
return <h1>{product.name}</h1>
}
What our test run showed
We ran the examples on 5 November 2026 with Next.js 16.4.3. An unknown slug returned status 404 with noindex, as documented. With a loading.tsx above it, though, the status was 200 and only the noindex remained. The Next.js docs describe this too: the response starts streaming before notFound() runs, so it keeps its 200 status. So wherever you have a loading boundary, check an invented URL with curl -I and don't rely on the code alone.
Metadata in the body: a surprise during audits
When generateMetadata is dynamic, Next.js can stream the metadata after the first output, and it ends up in <body>, not <head>. The docs say this has been verified for bots that run JavaScript, such as Googlebot. For "HTML-limited bots" such as facebookexternalhit, Next.js still blocks and writes the tags to the head, detecting them by user agent.
In our run the title ended up in the body when a Suspense boundary such as loading.tsx sat above it, with a normal browser user agent as well as Googlebot's. Without that boundary it stayed in the head, even with a data source delayed by 1.5 seconds. So look out for loading boundaries. For Google this isn't a fault, according to the docs. Whether an AI crawler reads a title in the body, the vendors don't say. To be safe, set htmlLimitedBots, which brought the title back to the head in our run. The docs warn this can lengthen response times and call the default sufficient for most cases.
// next.config.ts
import type { NextConfig } from 'next'
const config: NextConfig = {
htmlLimitedBots: /.*/,
}
export default config
Nuxt
The current documentation is for Nuxt 4, and we read version 4.6. If you're following Nuxt 3 examples, check them against it. The default is universal rendering: the server delivers the content in the HTML, and the browser takes over through hydration. The docs call useSeoMeta the recommended way to add meta tags.
<!-- pages/products/[slug].vue -->
<script setup lang="ts">
const route = useRoute()
const { data: product } = await useFetch(`/api/products/${route.params.slug}`)
if (!product.value) {
throw createError({ status: 404, statusText: 'Not found' })
}
useSeoMeta({
title: () => product.value?.name,
description: () => product.value?.summary,
})
</script>
<template>
<h1>{{ product?.name }}</h1>
</template>
With routeRules you set rendering per path. An ssr: false on a section search engines should see is the mistake to look for:
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
'/guides/**': { prerender: true },
'/account/**': { ssr: false },
},
})
One limit from our run with Nuxt 4.6.2: /guides/x became a static file with nuxt build --prerender and a link to the page. So after building, check that the file is really in the output folder.
Vue without Nuxt
Vue Router has two modes, and the difference decides your URLs. createWebHistory() produces normal addresses and is the recommended mode, according to its docs. createWebHashHistory() adds a hash, and the part after it never reaches the server. That's failure pattern 5 above.
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [],
})
History mode needs the right server configuration, or a direct visit to a subpage returns a 404, the docs warn. Conversely, check the server doesn't send the same shell with status 200 for every invented address. That would be failure pattern 6.
Vue's docs go further than Google on rendering. They say Google and Bing can index synchronous JavaScript apps, but that the crawler won't wait for content fetched after a loading spinner. Google doesn't describe it that way, and says only that it runs JavaScript. This changes little for your decision: for a few marketing pages, and for content such as documentation or blogs, Vue points to SSG over SSR. Comparing source and rendered HTML shows what actually arrives on your site.
React SEO without a framework
A plain React app built with Vite or similar delivers an empty shell. For public content you have three routes:
- Adopt a framework. The most thorough route, and often a rebuild.
- Pre-render at build time. For pages that rarely change. The server APIs in
react-domgenerate the HTML. - Separate the public part. Marketing and guides as static pages, with the app behind them as it is.
Since React 19 you can render <title> and <meta> from any component, and the docs say React always places them in the head. In our run with React 19.3.0 that held only when React also rendered the shell. When we embedded the output of renderToString as a fragment in our own <body>, title and meta stayed in the body. With <html> and <head> in the React tree they reached the head, even though they're written in the component:
// Card.jsx
export default function Card({ product }) {
return (
<article>
<title>{product.name}</title>
<meta name="description" content={product.summary} />
<h1>{product.name}</h1>
</article>
)
}
// prerender.mjs. First: esbuild Card.jsx --jsx=automatic --format=esm --outfile=Card.js
import { writeFileSync } from 'node:fs'
import { createElement as h } from 'react'
import { renderToString } from 'react-dom/server'
import Card from './Card.js'
const product = { name: 'Red shoe', summary: 'Example card' }
const html = renderToString(
h('html', { lang: 'en' },
h('head', null, h('meta', { charSet: 'utf-8' })),
h('body', null, h(Card, { product }))),
)
writeFileSync('red-shoe.html', `<!doctype html>${html}`)
renderToString is enough for this simple example. The React docs list it among the legacy APIs, though, and it supports neither streaming nor waiting for data. For those cases the docs point to prerender from react-dom/static, which we didn't run.
Astro on zds.es, and what it shows
This site runs on Astro too. According to its docs, Astro renders pages to static HTML and loads JavaScript only for islands you explicitly mark. There's more on that decision in our piece on headless CMS and Astro.
---
// Without client:* the component stays HTML, with no JavaScript in the browser
import Calculator from '../components/Calculator.jsx'
import Card from '../components/Card.astro'
---
<Card />
<Calculator client:visible />
We ran the diagnostic comparison on our own pages. On 5 November 2026 our Spanish glossary entry on JavaScript SEO had 2,567 words in the server's HTML and 2,591 rendered. The difference of 24 words is mostly the cookie notice. Title, H1 and canonical matched in both versions, and with JavaScript off the content stayed complete. The home page and one article showed the same picture.
That shows what this test looks like when nothing is missing. It doesn't show that Astro brings better rankings. Faults exist here as well, in other places. Four are documented on our site, and none involves JavaScript in the browser:
- Wrong error code (31 August 2026): a CMS fault produced 404 and not 503, which could have taken pages out of the index. Google's docs explain the difference: a 404 removes indexed URLs, while 5xx first slows crawling, and indexed URLs drop out only eventually.
- Capitals in paths (8 October 2026): they returned 200 with their own canonical, so duplicates. The redirect that fixed this caused a 301 loop on percent-encoded paths, found on 30 October 2026. A fix needs the same test as the fault.
- Languages taking different routes (5 November 2026): an SEO plugin set
nofollowonly through the CMS content filter. Spanish content ran through it, English and German came from their own fields and lacked it. A check in one language won't catch that. - Sitemap in memory: a service holds it for up to an hour. On 5 November 2026 the header showed 54 minutes. After an update,
lastmodcan briefly be stale with nothing actually broken.
Checklist
- Is a sentence of main content in the source?
- Are title, description and canonical in the head, once each?
- Are all important links
<a href>with real paths? - Does an invented URL return 404?
- Is there no noindex in the original HTML that's meant to go later?
- Are JavaScript and CSS free of robots.txt blocks?
- Do JavaScript files have a fingerprint in the name?
- Does content load without interaction, permissions, cookies or WebSockets?
- Does the rendered HTML in URL Inspection match what you expect?
Frequently asked questions
Can Google read JavaScript?
Yes. Google runs JavaScript with an up-to-date Chromium and uses the rendered HTML for indexing. There are limits: blocked files, content behind interaction or WebSockets and state from cookies aren't seen by Googlebot. So URL Inspection is still needed, even when the content shows in your browser.
How long does a page wait for rendering?
Google says a page may stay in the queue for a few seconds, but it can take longer. There's no fixed time. Older claims of days or weeks aren't in the current documentation of 4 March 2026. We have no measurement of our own on this.
Do I need SSR for SEO?
Not strictly for Google, which also renders client-side pages. Google still recommends server-side or pre-rendering, because it's faster and not all bots run JavaScript. For public pages you want found, SSR or SSG is the safe choice. Behind a login, you don't need it.
Should I set up dynamic rendering?
Not for new projects. Google calls it a workaround and not a long-term solution, and recommends server-side rendering, static rendering or hydration. Dynamic rendering means serving bots a different version from users, which you then have to maintain and monitor permanently.
Do ChatGPT and other AI services run JavaScript?
The crawler pages from OpenAI, Anthropic and Perplexity, read on 5 November 2026, don't say. We can't back a claim either way. With your content in the server's HTML, you don't need the answer. For your own site, server logs give hints but no proof.
Sources
Read on 5 November 2026.
- Google: JavaScript SEO basics (updated 4 March 2026)
- Google: Fix JavaScript issues
- Google: Dynamic rendering as a workaround
- Google: Crawlable links
- Google: HTTP status codes and crawling
- Next.js: generateMetadata and notFound
- Nuxt: useSeoMeta, rendering and createError
- Vue Router: history modes and Vue: SSR
- React: title and renderToString
- Astro: islands
- OpenAI: crawlers, Anthropic: crawlers and Perplexity: crawlers
Further reading on the concepts: JavaScript SEO, soft 404, canonical tag, noindex and log file analysis.
How this article was made
This article was first published in March 2026. It was rewritten in November 2026 with AI assistance, from the documentation linked above and our own sample of three pages, and reviewed before publication. The Next.js, Nuxt, React and Astro examples ran in test projects with Next.js 16.4.3, Nuxt 4.6.2, React 19.3.0 and Astro 7.4.2. HTML, robots.txt and script snippets were only checked for syntax or against test pages, and the Vue Router snippet wasn't run.