Skip to content

Web Development · SEO

Technical SEO for JavaScript Apps in 2026: What Google Actually Renders

Google's crawler runs JavaScript, but not the same way a browser does. The gap between what your JavaScript app renders and what gets indexed is where most SPA SEO problems hide.

Anurag Verma

Anurag Verma

9 min read

Technical SEO for JavaScript Apps in 2026: What Google Actually Renders

Sponsored

Share

Google’s crawler can execute JavaScript. This is true. It has been true since around 2015. What’s also true: Googlebot runs on a rendering queue that lags behind the initial crawl by seconds to minutes, executes JavaScript in a headless Chromium environment with different behaviors from a real browser, and does not wait indefinitely for async content to load.

The gap between “Google can render JavaScript” and “Google indexed everything my JavaScript app renders” is where most SPA SEO problems live.

How Google Actually Renders Pages

When Googlebot visits a URL, it goes through two phases:

Phase 1 — HTML crawl. Googlebot downloads the HTML, reads the <head>, finds links, extracts any content in the initial HTML response. This happens fast and synchronously.

Phase 2 — JavaScript rendering. Googlebot queues the URL for rendering by WRS (Web Rendering Service). WRS uses a headless Chromium (typically a recent but not latest version). It executes JavaScript, waits for a reasonable amount of network activity to settle, takes a snapshot of the rendered DOM, and sends the content to indexing.

The delay between Phase 1 and Phase 2 can be seconds, minutes, or in some cases hours. For newly published or updated content that matters for indexing speed, this delay is a real concern.

What WRS doesn’t do:

  • It doesn’t wait forever for slow API calls to resolve
  • It doesn’t log in or maintain session state
  • It doesn’t execute JavaScript that depends on browser APIs not available in headless Chromium (some media APIs, certain Web APIs)
  • It doesn’t trigger JavaScript initiated by user interaction (scroll, click, hover)

Content that only appears after a user scrolls down, clicks a tab, or interacts with the page is generally not indexed.

Rendering Approaches and Their SEO Implications

RenderingHow it worksInitial HTML has contentSEO suitability
CSR (client-side)Server sends empty shell, JS renders everythingNoPoor — depends entirely on WRS
SSR (server-side)Server renders full HTML on each requestYesGood — content in initial response
SSG (static generation)HTML pre-built at build timeYesExcellent — fastest for Googlebot
ISR (incremental static regen)Static with background revalidationYes (slightly stale ok)Good — behaves like SSG for crawlers
HybridPer-route decisionDepends on routeGood — use SSG/SSR for content pages

The key distinction: if the content you want indexed is in the raw HTML response before any JavaScript executes, Googlebot can index it in Phase 1. If it requires JavaScript, you depend on Phase 2 and the associated uncertainties.

The Common JavaScript SEO Failures

Lazy-loaded content that never enters the viewport for Googlebot. Intersection Observer-based lazy loading defers image or content loading until the element scrolls into view. Googlebot doesn’t scroll. Content below the fold that relies on scroll events to load is invisible to WRS.

For images: use loading="lazy" (native lazy loading) instead of Intersection Observer-based implementations. The browser spec includes consideration for non-interactive clients and Googlebot handles native lazy loading better.

For text content: don’t hide it behind scroll-triggered loading. If the content matters for SEO, it should be in the initial render.

Client-side navigation without canonical tags. When a user clicks an internal link in a React or Vue SPA, the URL changes via the History API and JavaScript renders the new content. From Googlebot’s perspective, this can create ambiguity about which URL is canonical. Ensure every route has a <link rel="canonical"> set server-side.

Metadata set by client-side JavaScript. If your <title> and <meta name="description"> are set by document.title = "..." in a useEffect, they appear correctly in the browser but may or may not be picked up by Googlebot depending on rendering timing.

With Next.js App Router or Astro, metadata is set server-side and appears in the initial HTML. With a pure SPA using React Helmet or similar, it’s set after JavaScript execution — valid for WRS but slower to index.

Fragile client-side routing with route-specific data fetching. Some SPAs only fetch page-specific data when the route is active. If Googlebot renders /product/123, it needs to trigger the data fetch that populates the page. This works if the data fetch happens quickly and WRS waits for it. It fails silently if the fetch exceeds WRS’s tolerance or fails for authentication reasons.

Testing What Google Actually Sees

The most reliable test: Google Search Console URL Inspection tool.

  1. Open Search Console for your property
  2. Go to URL Inspection, enter the URL
  3. Click “Test Live URL”
  4. Switch to the “HTML” tab to see what Google rendered, not what your browser sees

The rendered HTML tab shows exactly what WRS processed. If your dynamic content appears there, it will be indexed. If it’s absent, it won’t be.

For local development, run Lighthouse in your browser’s developer tools. The SEO audit catches missing meta tags, non-indexable pages (due to noindex), and some crawlability issues. It doesn’t simulate WRS behavior, but it catches the obvious failures.

For deeper testing, fetch-as-google (via the old Search Console) has been deprecated, but you can approximate it with:

# See what a simple HTTP request returns — Phase 1 view
curl -A "Googlebot/2.1 (+http://www.google.com/bot.html)" \
  "https://yourapp.com/your-page" | grep -E "<title>|<meta|<h1|<article"

If the content you care about appears in curl output, it’s in Phase 1. If it’s absent, it requires Phase 2.

Next.js App Router: Making Metadata and Content Reliable

Next.js App Router generates HTML server-side by default. Content in Server Components is in the initial HTML response. The generateMetadata function sets title, description, and Open Graph tags server-side.

// app/blog/[slug]/page.tsx
export async function generateMetadata(
  { params }: { params: { slug: string } }
): Promise<Metadata> {
  const post = await getPost(params.slug)

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: 'article',
      publishedTime: post.pubDate,
    },
  }
}

export default async function BlogPost({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug)

  return (
    <article>
      <h1>{post.title}</h1>
      {/* Content is in server-rendered HTML, visible in Phase 1 */}
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  )
}

The 'use client' directive moves rendering to the browser. Use it only for interactive components (search inputs, filters, carousels), not for content pages. A common mistake is marking an entire page layout as a client component because it has a single interactive element.

Astro: Static by Default

Astro generates static HTML at build time, which is the most Googlebot-friendly rendering mode. Pages are HTML files with no client-side JavaScript required for content rendering.

---
// src/pages/blog/[slug].astro
import { getCollection } from 'astro:content'

export async function getStaticPaths() {
  const posts = await getCollection('blog')
  return posts.map(post => ({
    params: { slug: post.slug },
    props: { post },
  }))
}

const { post } = Astro.props
const { Content } = await post.render()
---

<html>
  <head>
    <title>{post.data.title}</title>
    <meta name="description" content={post.data.description} />
  </head>
  <body>
    <h1>{post.data.title}</h1>
    <Content />
    <!-- Fully rendered HTML, no JavaScript required -->
  </body>
</html>

For content-heavy pages like blog posts, documentation, and marketing pages, static generation with Astro or Next.js ISR is the most reliable SEO foundation.

Core Web Vitals and Search Ranking

Since 2021, Core Web Vitals have been a ranking signal. In 2026, all three metrics matter:

LCP (Largest Contentful Paint) — how long until the largest text or image element is visible. For JavaScript apps, this is frequently hurt by loading spinners that delay meaningful content. The fix: render the page’s primary content server-side so it’s in the initial HTML.

CLS (Cumulative Layout Shift) — how much content moves around as the page loads. Common causes in SPAs: images without explicit width/height attributes, content injected above existing content, fonts loading and causing text reflow.

INP (Interaction to Next Paint) — time from user interaction to visual update. This replaced FID in March 2024. It’s a concern for JavaScript-heavy UIs with expensive event handlers or large React re-renders on interaction.

Run CrUX (Chrome User Experience Report) data in Google Search Console under Core Web Vitals to see real-user data for your domain. Field data from real Chrome users matters more for ranking than synthetic Lighthouse scores.

Structured Data

Structured data (JSON-LD) helps Google understand your content beyond just text. For blog posts, products, FAQs, and local businesses, adding JSON-LD to your <head> improves how Google displays your results.

<!-- In your <head> -->
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Article",
  "headline": "Your Post Title",
  "datePublished": "2026-05-23",
  "author": {
    "@type": "Person",
    "name": "Anurag Verma"
  },
  "description": "Article description here"
}
</script>

With Next.js App Router or Astro, generate this server-side so it appears in the Phase 1 HTML. JSON-LD in a useEffect is processed during Phase 2, which is less reliable.

The Practical Audit

If you are unsure about your current SEO posture, start here:

  1. Run URL Inspection on your 5 most important pages in Google Search Console. Check the rendered HTML tab.
  2. Check Core Web Vitals in Search Console under “Experience”. Look for pages with poor ratings.
  3. Run a Lighthouse SEO audit on a sample of pages. Fix any failing checks.
  4. Verify that every page has unique, server-rendered <title> and <meta name="description"> tags.
  5. Confirm internal links use real <a href> elements, not only JavaScript click handlers.

JavaScript frameworks don’t inherently hurt SEO. A Next.js App Router application or an Astro site can perform as well as a plain HTML site from an indexing standpoint. The problems come from defaulting to client-side rendering for content that should be server-rendered, from assuming WRS behaves like a real browser, and from not testing what Googlebot actually sees.

Sponsored

Enjoyed it? Pass it on.

Share this article.

Sponsored

The dispatch

Working notes from
the studio.

A short letter twice a month — what we shipped, what broke, and the AI tools earning their keep.

No spam, ever. Unsubscribe anytime.

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored