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.
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.
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.
- 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.
- 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.
- 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.
- 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.
- Confirm the headings, body copy, and any comparison data are literally present in the response, not just the loading state.
- Re-scan with AccessKnight and confirm the check reports 8 of 8 and the landmark, heading, and structured-data checks stopped reporting empty.
"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>// 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>
);
}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.
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”
Frequently asked questions
Do AI crawlers execute JavaScript?
Googlebot renders JavaScript, so why does this still matter?
Is client-side rendering bad for screen readers too?
How much text do I need for full marks?
My page uses Next.js App Router. Am I already fine?
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.