What Is SSR vs CSR and How Do You Fix Client-Side Rendering in 2026?

Vercel and MERJ measured more than half a billion AI crawler fetches and found no JavaScript execution at all, and no AI vendor has documented adding it since. This page defines every rendering mode in use today, says which crawlers can see each one, and gives you three fixes you can ship this week.

CategoryStructured data & machine readability StatusStable concern Maintained byFramework-dependent Glippy checkMachine Readability (category 6)

SSR vs CSR (server-side vs client-side rendering for AI crawlers) is the choice between building a page's HTML on the server before the response is sent, and building it in the browser after JavaScript has downloaded and run. It matters for AI visibility because a crawler that does not execute JavaScript sees only the first HTML response, so a client-rendered route arrives as an empty root element with a script tag. Every mode in between, static generation, ISR, streaming, partial prerendering and islands, is judged by one test: is the content in the bytes the server actually sent?

Why SSR vs CSR matters for AI visibility

The best public measurement is still the Vercel and MERJ server-log study of AI crawler traffic, published in December 2024. It found no JavaScript execution for GPTBot, OAI-SearchBot, ClaudeBot, PerplexityBot, Bytespider or Meta-ExternalAgent, and noted the awkward detail that these bots do download script files without running them: GPTBot fetched JavaScript in 11.50 percent of requests, ClaudeBot in 23.84 percent. Independent spot checks since then have reached the same conclusion from the other direction. Glenn Gabe's August 2025 case study asked ChatGPT, Perplexity and Claude to read client-rendered URLs and got "could not read the content", "Access Denied" and "returned without any visible content" back, while the same content on server-rendered control URLs came through fine. As of August 2026 no AI vendor has published a rendering policy or announced a rendering pipeline, so the honest summary is that the evidence is one solid log study plus a lot of consistent anecdote, and "assume no rendering" is the safe engineering default rather than a guarantee.

Per crawler, and being explicit about how strong the evidence is:

  • GPTBot (OpenAI, model training): no execution measured. OpenAI's own bot documentation lists the agent and its IP range file at openai.com/gptbot.json and says nothing at all about rendering.
  • OAI-SearchBot (surfaces sites in ChatGPT search): no execution measured, despite a user agent string that advertises a Chrome build. Treat the Chrome token as decoration, not a capability claim.
  • ChatGPT-User (user-initiated fetch from a chat): fetches HTML and stops. This is the one square that is moving. OpenAI folded the Atlas browser into ChatGPT's browser-based agentic work in August 2026, and that path drives a real Chromium engine, so an agent told to open your page can see client-rendered content that the crawler cannot. That covers one user at a time, not indexing.
  • ClaudeBot, with its siblings Claude-User and Claude-SearchBot: no execution measured for ClaudeBot. Anthropic documents what each of the three agents is for and how to block them individually, and makes no rendering claim for any of them. Evidence for the two newer agents is thin, so treat them like the crawler.
  • PerplexityBot and Perplexity-User: no execution measured. Perplexity's crawler docs give the exact user agent strings and IP range files and are silent on rendering.
  • Googlebot: renders fully in headless Chrome, and this is the pipeline behind AI Overviews and AI Mode. Google-Extended is not a crawler and never fetches anything: Google states it "doesn't have a separate HTTP request user agent string" and exists only as a robots.txt control token over how already-crawled content is used for Gemini training.
  • Applebot: renders. Apple's documentation, updated on 8 June 2026, warns that if "javascript, CSS, and other resources are blocked via robots.txt, it may not be able to render the content properly", which only makes sense for a crawler that renders. Applebot-Extended is a usage control token like Google-Extended and does no crawling of its own.

That splits the modern rendering menu cleanly. Static generation, ISR, plain SSR, streaming SSR with React Server Components and partial prerendering all put the content into the response body, and a crawler that reads the body to completion gets it, including the streamed chunks, because streaming is one HTTP response rather than a second request. Islands are half safe: the static shell is in the HTML, anything inside a hydrated island is not. Two patterns look server-rendered and are not. Hydration-only content is the common one, where the server emits headings and a skeleton and the real text arrives from a useEffect or onMounted fetch after hydration, so the crawler gets furniture and no facts. Deferred server islands are the subtler one: Astro's server:defer and any client-fetched Suspense boundary ship only the fallback in the initial HTML and pull the real component afterwards. For the framework-by-framework walkthrough see how to make an SPA readable for AI crawlers, and if your pages are server-rendered and still come back empty, why ChatGPT cannot read my website covers the other root causes.

Where the spec lives

There is no spec for this one. It is framework behaviour on one side and crawler operator documentation on the other, so these are the sources worth reading before you argue with anyone about it.

Three ways to implement SSR vs CSR

These are three different fixes for three different situations, not three flavours of the same one. The first is for a route whose content already exists behind an API and is being fetched in the browser for no good reason: move the fetch to the server. The second is for a codebase you do not want to rewrite: turn on prerendering per route in config and leave the components alone. The third is the last resort, for a page that genuinely has to stay client-side, where you inject the facts into the shell at the edge so the response body is never empty.

01

Move the fetch to the server with a React Server Component

Use this when the route is a content page (product, article, listing) whose data comes from an API you control. The failing version is a 'use client' component with a useEffect that fetches the product and sets state, which means the response body contains a spinner. In the Next.js App Router every component is a Server Component unless you opt out, so the fix is to delete the effect and await the data in the component itself, keeping only the genuinely interactive part behind a client boundary.

jsxapp/products/[slug]/page.js
// Next.js 16 App Router. No 'use client' here, so this runs on the server only
// and its output ships as HTML in the first response.
import { notFound } from 'next/navigation';
import AddToCart from './add-to-cart'; // the one 'use client' island on the page

async function getProduct(slug) {
  const res = await fetch(`https://api.acme.example/products/${slug}`, {
    next: { revalidate: 3600, tags: [`product:${slug}`] },
  });
  return res.ok ? res.json() : null;
}

export async function generateStaticParams() {
  const res = await fetch('https://api.acme.example/products?limit=200');
  const products = await res.json();
  return products.map((p) => ({ slug: p.slug }));
}

export default async function ProductPage({ params }) {
  const { slug } = await params;          // params is a Promise in Next.js 15+
  const p = await getProduct(slug);
  if (!p) notFound();                     // a real 404, not a client-side redirect

  return (
    <article>
      <h1>{p.name}</h1>
      <p>{p.summary}</p>
      <dl>
        <dt>Price</dt><dd>{p.price} {p.currency}</dd>
        <dt>Availability</dt><dd>{p.availability}</dd>
      </dl>
      <AddToCart sku={p.sku} />
    </article>
  );
}

What this does: the product name, summary, price and availability are serialised into the HTML before it leaves the server, so GPTBot and ClaudeBot read the same facts a browser shows, and generateStaticParams plus revalidate means most requests are served from a prebuilt file. The cart button is still a client island, but nothing an AI needs to quote lives inside it. Turning on cacheComponents: true in next.config.ts adds partial prerendering on top, which is safe here: the dynamic holes stream inside the same response rather than becoming a second request from the browser.

02

Prerender the content routes in config without touching components

Use this when the app already server-renders but you want build-time HTML on a CDN for the pages AI crawlers actually read, and you do not have budget to refactor. Nuxt's route rules apply rendering strategy per URL pattern in one file, which also gives you an honest place to declare that the authenticated app is client-only rather than leaving it to fail quietly.

javascriptnuxt.config.ts
// Nuxt 4 hybrid rendering: one strategy per route pattern, components unchanged.
export default defineNuxtConfig({
  compatibilityDate: '2026-08-01',
  routeRules: {
    // Built to static HTML at build time, served from the CDN.
    '/': { prerender: true },
    '/pricing': { prerender: true },
    '/about': { prerender: true },

    // Incremental static regeneration: HTML cached at the CDN, refreshed
    // on a TTL. Stale HTML is still HTML, which is what a crawler needs.
    '/blog': { isr: 3600 },
    '/blog/**': { isr: true },

    // Stale-while-revalidate on the server for stock-sensitive pages.
    '/products/**': { swr: 600 },

    // Client-only by choice. Nothing here should ever be cited, so keep it
    // out of the sitemap and disallow it in robots.txt as well.
    '/app/**': { ssr: false },
  },
  nitro: {
    prerender: {
      crawlLinks: true,
      routes: ['/sitemap.xml'],
    },
  },
});

What this does: every route a crawler cares about now returns finished HTML from cache, with no round trip to your origin and no JavaScript required. The ssr: false line is the important one for auditing: it is a written record of which URLs are deliberately invisible, so an empty Glippy result on /app/ is an expected outcome rather than a bug to chase.

03

Inject the content at the edge for a page that must stay client-side

Use this when the page cannot be server-rendered in the near term: a legacy Angular bundle, a widget you do not own, a build pipeline nobody will touch this quarter. The rule that keeps this honest is that the injected HTML goes to every visitor, not to detected bots, so it is not dynamic rendering and not cloaking. Cloudflare's HTMLRewriter streams the transform, so it costs almost nothing.

javascriptworkers/product-shell.js
// Cloudflare Worker in front of a client-rendered app. Same HTML for everyone:
// no user-agent sniffing, so this is progressive enhancement, not cloaking.
const API = 'https://api.acme.example';
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => '&#' + c.charCodeAt(0) + ';');

export default {
  async fetch(request) {
    const shell = fetch(request);
    const path = new URL(request.url).pathname;
    const match = path.match(/^\/products\/([a-z0-9-]+)\/?$/);
    if (!match) return shell;

    const res = await fetch(`${API}/products/${match[1]}`, {
      cf: { cacheTtl: 300, cacheEverything: true },
    });
    if (!res.ok) return shell;
    const p = await res.json();

    const html = `<article>
  <h1>${esc(p.name)}</h1>
  <p>${esc(p.summary)}</p>
  <p>Price: ${esc(p.price)} ${esc(p.currency)}. Availability: ${esc(p.availability)}.</p>
  <noscript><a href="/products/${esc(p.slug)}/spec">Full specification</a></noscript>
</article>`;

    return new HTMLRewriter()
      .on('#root', { element(el) { el.setInnerContent(html, { html: true }); } })
      .transform(await shell);
  },
};

What this does: the response body now contains the product facts instead of an empty <div id="root"></div>, so a non-rendering crawler has something to quote, while a browser mounts the app over the top and the visitor never notices. If you cannot run an edge worker at all, the weaker version is to bake the same summary into a <noscript> block in the static shell at build time. It is weaker because some text extractors skip noscript content, and because it goes stale the moment the data changes.

Implementation guidelines

These are the things that go wrong after the rendering mode is nominally fixed.

  1. Judge the response body, not the DOM. Run curl -sL https://example.com/page and read what comes back. DevTools shows you the hydrated DOM, which is exactly the thing no AI crawler ever sees, so a page can look perfect in the Elements panel and be empty on the wire.
  2. Streaming is fine, a second request is not. Streaming SSR and partial prerendering deliver the shell and the dynamic chunks inside one HTTP response, so a client reading to completion gets everything. The failure case is a boundary whose content is fetched by the browser afterwards, which includes useEffect fetches, client:only islands and Astro's server:defer.
  3. Prefer stale HTML to fresh JavaScript. ISR and stale-while-revalidate serve HTML that may be minutes old, and a crawler is happy with that. Do not swap a cached server render for a live client fetch to shave seconds off freshness, because the trade is content for nothing.
  4. Do not user-agent sniff. If you build a prerender proxy, serve its output to every request. Google explicitly describes dynamic rendering as "a workaround and not a long-term solution", and a bot-only path drifts from the real page until the two disagree about price, stock or availability.
  5. Return real status codes and real links. Client-side routing that answers 200 with an empty shell for a deleted product hides the 404, and a router that intercepts clicks without emitting an <a href> leaves a crawler with nothing to follow. Redirects should be HTTP redirects, not window.location assignments.
  6. Server-render the JSON-LD too. Structured data injected by a tag manager or added in a client effect is in the same position as the rest of your client-side content: absent from the response body. Emit it from the server, in the same render as the content it describes, and keep the two in agreement.
  7. Regression-test it in CI. A rendering fix survives exactly until the next refactor moves a fetch back into the client. Add a check that fetches each key template with no JavaScript and asserts a minimum body text length and the presence of the H1, then fail the build when it drops.

Do this, not that

Do

  • Put the H1, the body copy, the internal links and the JSON-LD in the first HTML response, and verify with curl rather than DevTools.
  • Prerender or ISR every content route (blog, docs, product, category) and reserve pure client rendering for authenticated screens.
  • Keep client components down to the parts that need input: cart buttons, filters, media players, not the copy around them.
  • Declare deliberately client-only routes in config, exclude them from the sitemap and disallow them in robots.txt so empty results are expected.

Do not

  • Do not fetch the page's main content in useEffect, onMounted or a client:only island.
  • Do not rely on server:defer or a client-fetched Suspense fallback for anything you want an AI to quote.
  • Do not serve different HTML to crawler user agents, and do not gate content behind a scroll, a click or a cookie banner.
  • Do not treat a passing Google rendering test as proof: Googlebot and Applebot render, GPTBot, ClaudeBot and PerplexityBot do not.

How Glippy checks this

Rendering mode lands in Machine Readability (category 6), which carries a weight of 1.5 in the overall score. Glippy fetches the raw HTML the way a crawler does and runs two checks on it. The ssr-content check measures extractable body text in that response: over 500 characters passes at 25 out of 25, between 100 and 500 warns at 15 with "may be CSR", and under 100 fails at 0 with "likely client-side rendered". The spa-framework check then looks for React, Vue, Angular and Svelte markers and, if it finds one, for a matching server-render fingerprint, meaning __NEXT_DATA__ or data-reactroot for React, __nuxt for Vue, ng-version for Angular and __sveltekit for Svelte. A framework with a fingerprint scores 10 out of 10; a framework without one warns at 5 with "ensure SSR is enabled for AI crawlers". The pairing is what makes the result useful: a low text count on its own can mean a thin page, but a low text count next to an unfingerprinted SPA marker is a rendering problem. See the machine readability checker for the full check list.

Check your SSR vs CSR setup

Glippy runs 240+ checks across 16 categories on any page, including Machine Readability (category 6). No sign-up required.

Frequently asked questions

No published evidence shows any of them running it. The Vercel and MERJ server-log study found zero JavaScript execution for GPTBot, OAI-SearchBot, ClaudeBot, PerplexityBot, Bytespider and Meta-ExternalAgent, even though GPTBot downloaded script files in 11.50 percent of requests and ClaudeBot in 23.84 percent. None of the three vendors documents a rendering capability, and none has announced one since. Assume they read the initial HTML response and nothing more.

Two families. Googlebot renders in headless Chrome and feeds AI Overviews and AI Mode, and Applebot renders too: Apple's documentation warns that blocking JavaScript and CSS in robots.txt stops it rendering properly. Note that Google-Extended and Applebot-Extended are not crawlers at all, only robots.txt tokens controlling how already-crawled content is used for AI training. Separately, agentic browsing sessions driven from a chat window use a real browser engine, but that serves one user at a time and does not build an index.

Rendering is only one of several causes that all look identical from the outside. The most common alternative is a bot block: a WAF, CDN bot-management rule or robots.txt entry returning 403 or a challenge page to GPTBot and OAI-SearchBot, which produces the same empty answer as client-side rendering. Check by requesting your page with the crawler's user agent and reading the status code, then check robots.txt for the citation crawlers specifically. Paywalls, consent interstitials and geo-blocking produce the same symptom.

It works, and Google does not treat it as cloaking, but Google's own documentation calls it "a workaround and not a recommended solution" because of the added complexity and cost. The practical risks are that the bot path drifts out of sync with the real page, and that user agent lists go stale the moment a vendor ships a new crawler name. If you need a stopgap, prefer injecting the same content into the shell for every visitor, which gets you the same result without maintaining a bot list.

Reviewed against the primary sources on . These standards move quickly, so check the linked specs before you ship.

Check your site for AI search readiness

Start free with the Glippy Chrome extension for instant page checks. Scaling up? Automate audits across many URLs and your whole sitemap with the Glippy MCP server.

Add Glippy to Chrome – free Automate with the MCP server →