JavaScript SEO FAQ: 11 questions frontend devs ask (with straight answers)
Every frontend team ends up having the same SEO conversation, usually right after marketing asks why the new React site gets less search traffic than the old WordPress one. Here are the questions that come up most often, with straight answers and code where it helps.
1. Does Google execute JavaScript?
Yes. Googlebot renders pages with an evergreen Chromium. But rendering happens in a second stage: the raw HTML is crawled first, and rendering is queued. For most sites the delay is short, but anything that exists only after rendering gets discovered later and less reliably than content in the initial HTML.
Other crawlers (many social preview bots, some search engines, plenty of SEO and AI tools) don't execute JavaScript at all.
Practical answer: put critical content and links in the server-rendered HTML when you can.
2. Do I need SSR or static generation?
Not strictly. Client-rendered apps can rank. But SSR or SSG removes a whole class of problems: slow rendering, render failures, missing meta tags for non-JS crawlers. If the pages are meant to rank (marketing, docs, product, blog), pre-render them. If it's a logged-in dashboard, don't bother.
3. How do I see what Google actually sees?
Use the URL Inspection tool in Search Console ("View crawled page" → HTML and screenshot). Locally, compare the raw response with the rendered DOM:
# raw HTML: what non-JS crawlers see curl -s https://example.com/pricing | grep -o '<title>.*</title>' # rendered DOM: roughly what a JS-capable crawler sees node render-diff.mjs https://example.com/pricing # script below A small Playwright script makes the comparison repeatable:
// render-diff.mjs usage: node render-diff.mjs https://example.com/pricing import { chromium } from "playwright"; const url = process.argv[2]; const raw = await (await fetch(url)).text(); const browser = await chromium.launch(); const page = await browser.newPage(); await page.goto(url, { waitUntil: "networkidle" }); const rendered = await page.content(); const links = (h) => (h.match(/<a\s[^>]*href=/gi) || []).length; console.log({ rawBytes: raw.length, renderedBytes: rendered.length, rawLinks: links(raw), renderedLinks: links(rendered) }); await browser.close(); If rawLinks is 0 and renderedLinks is 80, your whole internal linking structure depends on rendering.
4. Are onClick navigations crawlable?
No. Crawlers follow <a href="..."> elements. They don't click buttons or divs with handlers.
// ❌ not a link to a crawler <div onClick={() => navigate("/pricing")}>Pricing</div> // ✅ a real link; your router can still intercept it <Link to="/pricing">Pricing</Link> // renders <a href="/pricing"> 5. Are hash routes (/#/pricing) OK?
No. Everything after # is treated as a fragment of the same URL, so /#/pricing and /#/about are the same page to a search engine. Use the History API (/pricing), which every modern router supports.
6. Can I set the title and meta tags from JavaScript?
You can, and Google will usually pick them up after rendering. But non-JS crawlers (link previews in particular) will see whatever was in the initial HTML, often the same generic title on every route. With SSR or SSG frameworks, set metadata on the server:
// Next.js app router export const metadata = { title: "Pricing | Example", description: "Plans and pricing for Example.", alternates: { canonical: "https://example.com/pricing" }, }; 7. My SPA returns 200 for every URL. Is that a problem?
Yes, a real one. If /this-does-not-exist returns 200 with a "Not found" component, search engines see a soft 404, and junk URLs can get indexed. Fixes:
- With SSR: return a real 404 status for unknown routes.
- Pure client-side: add
<meta name="robots" content="noindex">on the not-found view, or redirect to a URL your server answers with a 404.
8. Does lazy loading hurt SEO?
Native loading="lazy" on images is fine. Content that only loads on scroll events or user interaction may never be seen, because crawlers don't scroll like users. Use IntersectionObserver-based loading (which works with how Googlebot renders tall viewports), or render the content up front.
9. Should I block JS and CSS files in robots.txt?
No. If the renderer can't fetch your bundles, it can't render the page. An old Disallow: /static/ rule left over from years ago is a classic cause of "Google sees a blank page".
10. Does bundle size affect rankings?
Indirectly. Big bundles slow down rendering and interaction, which shows up in Core Web Vitals (especially INP and LCP), and that's a page-experience signal. More directly: render timeouts and resource limits mean very heavy pages are more likely to be rendered incompletely.
11. How do I catch SEO regressions before they ship?
Treat SEO-critical output like any other contract and test it:
// seo.spec.ts (Playwright test) import { test, expect } from "@playwright/test"; for (const path of ["/", "/pricing", "/blog"]) { test(`seo basics ${path}`, async ({ request }) => { const html = await (await request.get(path)).text(); // raw, not rendered expect(html).toMatch(/<title>[^<]{10,}<\/title>/); expect(html).toContain('rel="canonical"'); expect(html).not.toContain('content="noindex'); }); } Before launch: a five-minute raw-HTML check
If you only have time for one pass before a JavaScript site goes live, run these against the raw response, without rendering, because that's the version every crawler gets first:
URL=https://example.com # 1. Unknown routes must not return 200 curl -s -o /dev/null -w '%{http_code}\n' "$URL/this-should-not-exist-123" # 2. Title, canonical and robots meta are present in the raw HTML curl -s "$URL/pricing" | grep -oiE '<title>[^<]*</title>|<link[^>]+rel="canonical"[^>]*>|<meta[^>]+name="robots"[^>]*>' # 3. Real links exist before JavaScript runs curl -s "$URL/" | grep -oiE '<a\s[^>]*href="/[^"]*"' | wc -l # 4. robots.txt doesn't block your bundles curl -s "$URL/robots.txt" | grep -iE '^disallow' How to read the output:
- Anything other than
404(or410) for the junk URL is a soft-404 problem (question 7). - An empty result, or the same title on every route you try, means metadata is only being set client-side (question 6).
- A count near zero means your navigation depends on rendering (questions 3 and 4).
- Compare every
Disallowline with the paths your JS and CSS bundles load from (question 9).
Four commands, no extra tooling, and they catch the problems that most often explain why the new site gets less search traffic than the old one.
For ongoing checks across a whole site (titles, canonicals, indexability and more), an audit tool such as seotto.com can crawl it on a schedule. The CI test above stops the obvious mistakes from ever merging.
Related reading
- View source vs Inspect element: the SEO bug hiding between raw and rendered HTML
- How to check if robots.txt is blocking Google (and fix it without breaking anything)
- Find duplicate and missing title tags with a Python crawl script
- Image SEO: 7 things I'd do differently on the next big site (starting with hashed filenames)
What JavaScript SEO question does your team argue about most? Drop it below. If there's a pattern, I'll write a follow-up with test cases for it.