JavaScript SEO
Search engines execute JavaScript - but later, partially, and at a cost. JavaScript SEO is the discipline of making sure content that depends on JS still gets crawled, rendered and indexed reliably. For developers shipping React/Vue/Svelte apps, this is the technical-SEO area most likely to bite you.
The two-wave crawl#
Googlebot processes pages in two phases:
Wave 1: fetch HTML → parse links, index server-rendered content (immediate)
Wave 2: render JS → headless Chromium executes your bundle (delayed: minutes to days)Everything that matters should survive wave 1:
- Content present in initial HTML → indexed immediately and reliably
- Content that exists only after JS execution → indexed late, sometimes never (render queue, timeouts, errors)
- Links that exist only after JS → discovered late, weakening crawl of deeper pages
- Other crawlers (some AI/social bots) don't execute JS at all - relevant for GEO
Rendering strategies ranked#
| Strategy | Initial HTML | SEO reliability |
|---|---|---|
| SSG / ISR (prerendered) | Complete | Best - and fastest |
| SSR (per-request) | Complete | Excellent |
| CSR (client-only SPA) | Empty shell | Fragile - depends entirely on wave 2 |
The rule: content pages must arrive as server-rendered HTML. In Next.js App Router this is the default - pages are Server Components, prerendered when possible. You opt into fragility by fetching content with useEffect in client components.
// ❌ Content exists only after client-side fetch
"use client";
export default function Post() {
const [post, setPost] = useState(null);
useEffect(() => { fetch(`/api/post`).then(/* … */); }, []);
return post ? <Article post={post} /> : <Spinner />;
}
// ✅ Content is in the HTML Googlebot fetches
export default async function Post({ params }) {
const post = await getPost((await params).slug);
return <Article post={post} />;
}The classic failure modes#
Links that aren't links#
Crawlers follow <a href>. They do not click divs:
// ❌ undiscoverable
<div onClick={() => router.push("/pricing")}>Pricing</div>
// ✅ a real anchor (Next.js <Link> renders one)
<Link href="/pricing">Pricing</Link>Client-side-only metadata#
Titles, canonicals and meta set via client JS arrive in wave 2 at best. Use the server-side Metadata API so they're in the initial HTML.
Soft 404s#
A SPA that renders "Not found 😢" with HTTP 200 poisons the index with junk URLs. Return a real 404 - in Next.js, call notFound() from the page.
Content behind interaction#
Tabs, accordions and "load more" content that requires a click to fetch doesn't exist for crawlers. If it must be indexed, render it in the HTML (hidden-but-present is fine) or give it its own URL.
Blocked resources#
If robots.txt disallows your /static/ JS or API routes the renderer needs, wave 2 sees a broken page. Let crawlers fetch your assets.
How to verify what bots see#
curlthe page - is your content in the raw HTML?
curl -s https://example.com/post | grep -c "your headline"- URL Inspection in Search Console → "View crawled page" shows the rendered DOM and a screenshot - ground truth for wave 2.
- Disable JS in DevTools and reload: whatever survives is your wave-1 reality.
- Crawl with JS rendering on/off (Screaming Frog supports both) and diff the discovered links - the delta is your JS dependency.
With rendering under control, the technical module is done. Next: Off-Page SEO - authority signals beyond your own domain.
