Skip to content
All AI readability rules
CriticalShared Core8 of 40 ptsRendering

How to fix: Key content missing from the server-rendered HTML

Server-rendered content is text that is already present in the HTML response before any JavaScript runs, and it is what AI retrieval crawlers read: if your headings and copy only appear after the browser hydrates the page, move that rendering to the server so the content exists in the initial response.

The check

What is this?

Server-rendered content is the text that exists in the HTML response before any JavaScript runs. AccessKnight fetches that raw response and measures how much readable text it contains. If your headings, copy, and data only appear after the browser hydrates the page, a client that does not render JavaScript receives an empty shell.

The crawlers split into two camps, and the distinction is worth getting right. Google documents its own behaviour plainly: Googlebot queues every page for rendering, and a headless Chromium executes the JavaScript once resources allow. The retrieval crawlers — OAI-SearchBot, ChatGPT-User, PerplexityBot, and Claude's fetchers — are observed to work from the HTML response instead, but note the asymmetry in evidence: OpenAI's crawler documentation describes what its bots do and never mentions JavaScript at all.

The cost compounds inside the score. The shared-core analyzer parses one raw HTML string for all five of its checks, and the Extractability grader reads a text sample taken from that same string. So a client-rendered page does not simply lose the eight points for this check: its landmarks and headings are absent from the HTML too, and the grader has almost nothing to work with. One rendering decision moves most of a 100-point scan.

The impact

Who does this affect, and why does it matter?

Every AI surface that reads HTML rather than pixels. OpenAI's OAI-SearchBot fetches pages to surface them in ChatGPT's search features; Perplexity uses PerplexityBot; Claude uses Claude-SearchBot and Claude-User. Google AI Overviews and AI Mode are built on Google's index, which does render — but only after the deferred render pass has actually run on your page.

The human consequence is indirect here, and it is worth being precise rather than reaching for the usual story. Screen readers run inside a real browser with JavaScript enabled, so hydrated content does reach them, and no WCAG rule covers server rendering. The genuine overlap is different and still real: a server-rendered page survives a failed script, a slow network, and a low-powered device, and it is the precondition for the other four Shared Core checks having anything to find.

AccessKnight scores this check at 8 of the 40 points in the Shared Core pillar, and it reports at critical severity whenever the raw HTML holds under 500 characters of text. The mechanism is plain: text that is not in the HTML response cannot be read by a client that does not run JavaScript, and nothing can retrieve what it cannot read.

That is a negative claim about a blocked path, and it is the only kind worth making. Google's own guidance states there are no additional requirements or special optimizations needed to appear in its AI surfaces, and that is the honest frame for this whole rule. Server rendering is not an AI tactic. It is ordinary web engineering that was always correct, and the retrieval crawlers merely gave it a second reason. It removes a blocker; it promises no engine's output.

The fix

How do I fix it?

Move the rendering of your key content from the browser to the server, then confirm it by reading View Source rather than the DevTools Elements panel. In most frameworks this means removing a client-only opt-out rather than rewriting anything. Interactive parts can stay on the client as small islands around markup that already exists.

  1. See the page the way a non-rendering crawler does: open view-source:https://yourdomain.com/your-page, or run curl -s https://yourdomain.com/your-page. Read that markup, never the DevTools Elements panel, which shows the hydrated DOM and hides the problem completely. A raw byte count is not the number this check reports — it strips script, style, noscript, template, and svg first, then counts what is left of the body text.
  2. Find the components whose text is missing from that markup, then look for your framework's opt-out. In Next.js it is a next/dynamic import with ssr: false, or a client component that fetches its content in useEffect. In Nuxt it is a ClientOnly wrapper or ssr: false in nuxt.config. In SvelteKit it is export const ssr = false. In a Vite, Create React App, or default Angular build it is not an opt-out at all — nothing renders on the server unless you add a rendering layer.
  3. Move the data fetch to the server: a React Server Component, getServerSideProps, getStaticProps, or your framework's equivalent. Keep the interactive parts as small client islands around already-rendered markup.
  4. If the framework cannot server-render a route, pre-render it at build time or put a prerendering layer in front of it, so the crawler still receives finished HTML.
  5. Confirm the headings, body copy, and any comparison data are literally present in the response, not just the loading state.
  6. Re-scan with AccessKnight and confirm the check reports 8 of 8 and the landmark, heading, and structured-data checks stopped reporting empty.
app/pricing/page.tsx — Before
"use client";
import dynamic from "next/dynamic";

// ssr: false — the plans never reach the HTML response.
const PricingTable = dynamic(() => import("@/components/PricingTable"), {
  ssr: false,
  loading: () => <p>Loading…</p>,
});

export default function PricingPage() {
  return (
    <main>
      <h1>Pricing</h1>
      <PricingTable />
    </main>
  );
}

<!-- What a non-rendering crawler receives: -->
<main><h1>Pricing</h1><p>Loading…</p></main>
app/pricing/page.tsx — After
// Server component — no "use client", so this runs on the server.
import { getPlans } from "@/lib/plans";
import PlanActions from "@/components/PlanActions"; // client island

export default async function PricingPage() {
  const plans = await getPlans();
  return (
    <main>
      <h1>Pricing</h1>
      <table>
        <caption>Plan comparison</caption>
        <thead>
          <tr><th scope="col">Plan</th><th scope="col">Price</th></tr>
        </thead>
        <tbody>
          {plans.map((p) => (
            <tr key={p.id}>
              <th scope="row">{p.name}</th>
              <td>{p.price}</td>
            </tr>
          ))}
        </tbody>
      </table>
      <PlanActions />
    </main>
  );
}
Detection

How does AccessKnight detect this?

AccessKnight requests the page over plain HTTP with the user agent AccessKnight-MachineReadability/1.0 and never runs a browser, so the HTML it measures is exactly what a non-rendering client receives. It loads that response, strips script, style, noscript, template, and svg elements, collapses the remaining body text to single spaces, and counts the characters. The bands are fixed: under 150 characters scores 0 of 8, under 500 scores 2, under 1,500 scores 5, and 1,500 or more scores the full 8. It also watches for an empty single-page-app shell — a #root, #app, or #__nuxt element on a page holding under 200 characters of text — and when it finds one the report names the shell explicitly instead of only reporting a low count. Read that carefully: the shell flag changes the wording of the finding, not the score, because the character count alone sets the points. And because the same raw HTML feeds the landmark, heading, accessible-name, and JSON-LD checks, and the Extractability grader reads a sample taken from it, a client-rendered page loses points across the whole scan rather than on this check alone.

On screen right now

What strings mean this?

If one of these strings is in front of you right now — in a config file, an HTTP header, an AccessKnight report, or another checker’s output — it is describing the failure explained on this page.

AccessKnight report
The raw HTML looks like an empty SPA shell — AI crawlers and many agents do not run JavaScript, so they see almost no content.
AccessKnight report
Server-render or pre-render key content so it exists in the initial HTML response.
AccessKnight report
Content present in server-rendered HTML
HTML
<div id="root"></div>
next/dynamic
ssr: false
FAQ

Frequently asked questions

Do AI crawlers execute JavaScript?

The retrieval crawlers are generally observed not to. OAI-SearchBot, ChatGPT-User, PerplexityBot, and Claude's fetchers request the page over HTTP and work from the HTML that comes back. Be aware this is observation rather than a published guarantee — none of those vendors documents its rendering behaviour either way. Googlebot is the documented exception: Google states that it queues every page for rendering and runs a headless Chromium once resources allow.

Googlebot renders JavaScript, so why does this still matter?

Two reasons. Google's render pass is deferred and budget-limited, so the rendered version can lag the crawled one by an unpredictable amount. And Google is not the only path: OAI-SearchBot, PerplexityBot, and Claude's fetchers each retrieve independently, and none of them inherits Google's rendering behaviour. Server-rendering removes the dependency on any single vendor's renderer.

Is client-side rendering bad for screen readers too?

Mostly no, and it is worth being precise about that. Screen readers run inside a real browser with JavaScript enabled, so they read the hydrated DOM like any other user. This is the one Shared Core check where the accessibility overlap is weak. The real overlap is that server-rendered content survives a failed script, a slow connection, and a cheap device — and that the other Shared Core checks have nothing to find until the HTML has content in it.

How much text do I need for full marks?

1,500 characters of text in the raw HTML, after script, style, noscript, template, and svg elements are stripped and whitespace is collapsed. Under 1,500 scores 5 of 8, under 500 scores 2, and under 150 scores 0. That is roughly 250 to 300 words of real body copy, which any page with a genuine article or product description clears without effort.

My page uses Next.js App Router. Am I already fine?

By default yes, because App Router components render on the server unless you opt out. Three things opt you out: a next/dynamic import with ssr: false, a component that fetches its content in useEffect after mount, and a client component that renders nothing until a client-side data hook resolves. Check View Source rather than the DevTools Elements panel, which shows the hydrated DOM and hides the difference.
Sources

Where does this come from?

Every external claim on this page traces to one of the documents below — the vendors’ and standards bodies’ own documentation. What AccessKnight adds is the scan, not the standard.

Is this check
failing on your site?

AccessKnight scans any page against all 18 AI-readability checks — including this one — and shows every issue with a fix. Free, no credit card.

✓ Free — no credit card✓ Nothing installed on your site✓ WCAG + AI readability