Skip to content

Web Development · SEO

Technical SEO for JavaScript Apps in 2026

Google's crawler runs JavaScript, but on a delay and with different limits than a browser. Here's where JS app SEO actually breaks, and how to test for it.

Anurag Verma

Anurag Verma

9 min read

Technical SEO for JavaScript Apps in 2026

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

Google renders JavaScript apps in two phases: it crawls the raw HTML immediately, then queues the page for a separate JavaScript rendering pass by its Web Rendering Service (WRS), which can lag behind the initial crawl by seconds, minutes, or hours. Content that depends on that second phase indexes slower and less reliably than content already present in the first.

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

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. For the broader App Router migration and rendering model, see our Next.js 15/16 App Router guide.

// 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. We cover why we made that call for our own agency site in why we ditched Next.js for Astro, and how the two frameworks compare more broadly in Next.js 15 vs. Astro 5 vs. Remix.

Core Web Vitals and Search Ranking

Since 2021, Core Web Vitals have been a ranking signal according to Google’s own Search Central documentation. 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, and it’s a concern for JavaScript-heavy UIs with expensive event handlers or large React re-renders on interaction. For a deeper walkthrough of measuring and fixing INP specifically, see our Core Web Vitals INP guide.

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.

Frequently asked questions

Does Google index JavaScript-rendered content?
Yes, but on a delay. Googlebot crawls the raw HTML immediately (Phase 1), then queues the page for JavaScript rendering by its Web Rendering Service (Phase 2), which can take anywhere from seconds to hours depending on crawl budget and site size.
Why isn't my lazy-loaded content showing up in Google search?
If it loads via an Intersection Observer that only fires on scroll, Googlebot never triggers it because WRS doesn't scroll the page. Switch to native `loading="lazy"` on images, or make sure text content that matters for SEO renders without any scroll or click event.
Is server-side rendering necessary for SEO in a JavaScript app?
Not strictly necessary, since Googlebot can render client-side JavaScript, but SSR or SSG makes indexing faster and more reliable because the content is already in the initial HTML response instead of depending on the slower Phase 2 rendering queue.
How can I check what Google actually sees on my page?
Use Google Search Console's URL Inspection tool, click 'Test Live URL', then switch to the HTML tab. That shows the DOM exactly as WRS rendered it, which is the authoritative view of what will be indexed, not what your browser or a Lighthouse audit shows.
Do Core Web Vitals affect SEO for JavaScript-heavy sites?
Yes. LCP, CLS, and INP have been ranking signals since 2021 (INP replaced FID in March 2024), and JavaScript apps commonly underperform on all three due to loading spinners delaying LCP, hydration shifting layout, and heavy event handlers slowing INP.

Sponsored

Sponsored

Discussion

Join the conversation.

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

Sponsored