If you Google "best rendering for SEO," you'll get a hundred blog posts that all say the same thing: "Use SSR or SSG because Google doesn't crawl JavaScript well." Then they show you a 2018 diagram and stop there. That's not useful in 2026. Googlebot can render JavaScript. The question isn't "does Googlebot run JS?" — the answer is yes, it has since 2019. The real question is whether you want to spend your SEO budget on the parts of the rendering pipeline that hurt rankings, and the answer to that is almost always no.
So this is the post I want to send to my team when they ask which rendering method to use for a new project. No framework religion. Just the actual mechanics, where each strategy hurts and helps, and a decision matrix you can apply today.
The thing nobody explains: how Googlebot actually works
Before we compare rendering methods, you need to understand what Googlebot is doing when it visits your page. It runs a two-wave pipeline. This is the part that decides whether your SEO is good or tragically bad.
Wave 1 — the raw HTML crawl. Googlebot fetches the HTML, parses it, and follows every link it finds. Canonical tags, meta robots, hreflang, structured data — all of that has to be in the initial HTML. If you inject your <title> with useEffect, Googlebot reads an empty title in Wave 1 and only sees the real one in Wave 2.
Wave 2 — the headless Chromium render. A separate process, running on a render queue, loads your page in a real browser, executes the JavaScript, waits for the network to settle, then re-parses the rendered DOM for more links and full content. This is the part that handles "but Google can run JavaScript, right?" — yes, but the render queue is non-guaranteed, and Google themselves say "it can take longer" without telling you how long. In practice it varies from seconds to weeks for the long tail.
Here's the trap almost every CSR site falls into:
<!-- Wave 1: Googlebot sees... this -->
<!DOCTYPE html>
<html>
<head>
<title>My Site</title> <!-- generic -->
</head>
<body>
<div id="root"></div>
<script src="/app.45a3c.js"></script>
</body>
</html>
Then the JS runs in Wave 2 and the actual title becomes "Best Running Shoes for Flat Feet — 2026 Buyer's Guide". But Wave 1 already followed the wrong links, indexed the wrong canonical, and put your page in the wrong bucket. The longer the render queue takes, the longer your page sits in limbo.
The fix is to put the real metadata in the initial HTML. That decision alone dwarfs every other choice you'll make.
The Core Web Vitals angle
SEO in 2026 is not just "is the HTML crawlable." It's also Core Web Vitals — INP, LCP, CLS — which are confirmed ranking signals, modest but real. Each rendering strategy hits these differently.
// What the browser actually sees for each strategy
interface RenderStrategy {
ttfb: number; // time to first byte
lcp: number; // largest contentful paint — depends on JS chunks
cls: number; // cumulative layout shift — depends on hydration
inp: number; // interaction to next paint — depends on JS work
crawlerRisk: 'low' | 'medium' | 'high';
}
SSG has the best CWV profile by default. The HTML is on a CDN, TTFB is whatever the edge is (~50–200ms globally), LCP is the HTML itself, CLS is zero because nothing resizes, and INP is whatever client JS you ship. The crawler risk is essentially zero — the HTML is fully indexable, no script execution required.
ISR is SSG with a timer. Same CWV profile, same crawler risk, same HTML quality. The only difference is staleness — you trade freshness for the same great performance.
Streaming SSR plus RSC is the modern default for Next.js App Router. The server streams the shell immediately, then streams dynamic holes as their data resolves. LCP is fast because the shell paints fast. CLS can suffer if you don't size your Suspense boundaries, but if you do, it's close to SSG. INP is excellent because very little JS ships to the client. Crawler risk is low as long as the canonical, title, and JSON-LD are in the shell.
Classic SSR (render the whole page on the server, then hydrate it) is fine for SEO but historically worse for CWV because TTFB is higher and the client pays full hydration cost. Think of it as "RSC, but with more JS."
CSR is the worst on every dimension. High TTFB (the HTML is just a shell), high LCP (LCP fires after the JS finishes loading), possible CLS (the shell resizes when data arrives), high INP (the browser is busy parsing your framework). Crawler risk is high not because Google can't render, but because every step in the pipeline is a place where something can go wrong.
The ranking, with reasoning
Here's how I'd back-rank these for SEO outcomes in 2026, with the specific failure mode of each:
┌─────────────────────────────────────────────────────────────┐
│ Tier 1 — Best for SEO │
│ • SSG (with on-demand revalidation) │
│ • ISR │
│ • PPR (Next.js) │
├─────────────────────────────────────────────────────────────┤
│ Tier 2 — Good for SEO │
│ • Streaming SSR (RSC + Suspense) │
│ • Classic SSR (Next.js getServerSideProps) │
├─────────────────────────────────────────────────────────────┤
│ Tier 3 — Worse for SEO │
│ • CSR (SPA) — works, but eats your crawl budget │
└─────────────────────────────────────────────────────────────┘
The Tier 1 methods all share one trait: the HTML a crawler sees in Wave 1 is the HTML a user sees in the browser. The shell is real, the title is real, the structured data is real. There's no "trust me, the JS will fix it" handoff.
The Tier 2 methods ship real HTML too, but they pay a small TTFB cost on every request. That cost is usually within Google's "good" threshold, so ranking is fine — just don't expect SSG-tier LCP.
Tier 3 isn't a death sentence. Googlebot can render your SPA. But you have to be paranoid about: structured data being rendered too late, soft-404s caused by the app shell routing, hash-based routing (#/products/1), JS errors that break the render, and the render queue taking too long for your time-sensitive content.
The decision matrix per route
The right answer is almost never "use SSG for the whole app" or "use SSR for the whole app." It's per-route. Here's how I think about it:
// A heuristic that works for most content sites
function pickRenderStrategy(route: Route): RenderStrategy {
// Marketing pages, blog posts, docs — fully static, on-demand revalidate
if (route.isContentPage && !route.requiresAuth) {
return 'SSG with on-demand revalidation';
}
// Product pages, listings — static shell, dynamic data hole
if (route.isCatalogPage) {
return 'PPR (static shell + streamed price/availability)';
}
// Authenticated dashboards, user-specific pages — SEO doesn't matter
if (route.requiresAuth) {
return 'SSR with noindex (RSC for performance)';
}
// Search results, anything that varies per user but is public
if (route.isPublicPersonalized) {
return 'Streaming SSR with skeletons';
}
// Real-time data — only the hols in the shell run on the server
if (route.isRealTime) {
return 'SSG shell + client-side data fetching';
}
}
Let me walk through each tier with real code.
Tier 1a: SSG with on-demand revalidation
This is the correct default for blog posts, marketing pages, docs, and any content that updates rarely. The HTML is built once, served from a CDN, and you revalidate it on demand when the content actually changes.
// app/posts/[slug]/page.tsx (Next.js App Router)
export const revalidate = false; // never revalidate on a timer
export async function generateStaticParams() {
const posts = await db.post.findMany({ select: { slug: true } });
return posts.map(p => ({ slug: p.slug }));
}
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await db.post.findUnique({ where: { slug: params.slug } });
if (!post) notFound();
return <Article post={post} />;
}
// Trigger revalidation when the content changes
// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache';
export async function POST(req: Request) {
const { slug, secret } = await req.json();
if (secret !== process.env.REVALIDATE_SECRET) {
return new Response('Invalid', { status: 401 });
}
revalidatePath(`/posts/${slug}`);
return Response.json({ revalidated: true });
}
The metadata pattern you absolutely must use:
// app/posts/[slug]/page.tsx
export async function generateMetadata({ params }: { params: { slug: string } }) {
const post = await db.post.findUnique({ where: { slug: params.slug } });
if (!post) return {};
return {
title: post.title,
description: post.excerpt,
alternates: { canonical: `https://yoursite.com/posts/${post.slug}` },
openGraph: {
title: post.title,
description: post.excerpt,
images: [{ url: post.coverImage, width: 1200, height: 630 }],
type: 'article',
publishedTime: post.publishedAt.toISOString(),
},
twitter: { card: 'summary_large_image' },
};
}
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await db.post.findUnique({ where: { slug: params.slug } });
// JSON-LD co-located with the page — ships in the initial HTML
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.title,
author: { '@type': 'Person', name: post.author.name },
datePublished: post.publishedAt.toISOString(),
image: post.coverImage,
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<Article post={post} />
</>
);
}
Notice the JSON-LD is rendered as part of the initial HTML, not injected client-side. This is what survives Wave 1.
Tier 1b: ISR — static with a timer
// app/products/[handle]/page.tsx
export const revalidate = 3600; // refresh every hour
export async function generateStaticParams() {
const products = await db.product.findMany({ select: { handle: true } });
return products.map(p => ({ handle: p.handle }));
}
Good for product catalogs where the price can be slightly stale but you want SSG-tier performance. The ranking impact is identical to pure SSG.
Tier 1c: PPR (Partial Pre-Rendering, Next.js)
This is the modern sweet spot. The static shell renders at build time. The dynamic holes are streamed at request time. Googlebot sees a complete HTML document in Wave 1 because the static shell includes the title, canonical, and JSON-LD — the dynamic data is in Suspense holes that stream in.
// app/products/[handle]/page.tsx
import { Suspense } from 'react';
export default function ProductPage({ params }: { params: { handle: string } }) {
return (
<>
<ProductHeader handle={params.handle} /> {/* static, in shell */}
<Suspense fallback={<PriceSkeleton />}>
<ProductPrice handle={params.handle} /> {/* dynamic, streamed */}
</Suspense>
<Suspense fallback={<StockSkeleton />}>
<ProductStock handle={params.handle} /> {/* dynamic, streamed */}
</Suspense>
</>
);
}
The shell is HTML-complete for SEO. The streamed parts are bonus content that improves UX but doesn't have to be indexed for the page to rank. This is why PPR is the new default for content sites on Next.js.
Tier 2: Streaming SSR with RSC
// app/dashboard/page.tsx
export const dynamic = 'force-dynamic';
export const metadata = {
robots: { index: false, follow: false }, // dashboard, no SEO
};
export default async function DashboardPage() {
return (
<Suspense fallback={<DashboardSkeleton />}>
<DashboardData />
</Suspense>
);
}
For SEO-relevant pages that are still dynamic (e.g., search results), this is the right pick. The shell renders in ~100ms, the data streams in, and the user sees a meaningful page fast. Crawlers can index the shell content; dynamic data is a bonus.
Tier 3: CSR — when you have to
If you're building a logged-in app (Gmail, Linear, Figma), SEO is irrelevant. You can ship CSR. But if you have even one public URL that needs to rank, ship that one as SSG/SSR and keep the rest as CSR.
// app/login/page.tsx — public, needs SEO for the brand
export const dynamic = 'force-static';
export default function LoginPage() {
return <LoginForm />; // static HTML, login JS
}
// app/inbox/page.tsx — authenticated, no SEO
'use client';
export default function InboxPage() {
const { data } = useQuery(['emails'], fetchEmails);
return <EmailList emails={data} />; // CSR, noindex
}
The metadata checklist that actually matters
Forget which rendering method you choose for a second. If you get this list wrong, none of the above matters:
// Mandatory HTML in the initial response, before any JS runs:
// 1. <title> with the actual page title (not "My Site")
// 2. <meta name="description"> with the actual page description
// 3. <link rel="canonical"> pointing to the canonical URL
// 4. <meta name="robots"> with index/follow or noindex
// 5. JSON-LD structured data for the content type
// 6. OpenGraph tags (og:title, og:image, og:url, og:type)
// 7. <html lang="..."> matching the content language
// 8. hreflang tags if you have multi-language versions
// 9. A meaningful <h1> near the top of the body
// 10. Internal links in real <a href="..."> tags, not JS click handlers
Every one of these has to be in the source HTML. If any of them is behind a useEffect, you're betting your SEO on Google's render queue.
The TL;DR
- SSG, ISR, and PPR are your best friends for SEO. Use them for everything that doesn't require per-user data.
- Streaming SSR with RSC is the right pick for dynamic public pages.
- CSR works for SEO but eats your crawl budget and is fragile.
- The most important SEO decision is what's in the initial HTML, not which rendering method you picked.
- Don't optimize for SEO on authenticated routes.
<meta name="robots" content="noindex">and move on.
Pick the strategy per route, ship the right HTML in Wave 1, and the rest of Google's ranking signals will fall into place. The framework of the month will change. The two-wave crawl won't.