seo101

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#

StrategyInitial HTMLSEO reliability
SSG / ISR (prerendered)CompleteBest - and fastest
SSR (per-request)CompleteExcellent
CSR (client-only SPA)Empty shellFragile - 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.

❌ invisible to wave 1 vs ✅ server-rendered
// ❌ 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#

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#

  1. curl the page - is your content in the raw HTML?
curl -s https://example.com/post | grep -c "your headline"
  1. URL Inspection in Search Console → "View crawled page" shows the rendered DOM and a screenshot - ground truth for wave 2.
  2. Disable JS in DevTools and reload: whatever survives is your wave-1 reality.
  3. 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.