How to check if server side rendering is working

Read the response body, not the browser: curl -sS URL, and confirm the page's words are already in it. Rendering done on the server puts the text in the first response. The local hydration case below served 17 words and reached 23 only after scripts ran, and both states look identical in a browser.

Checker offline. Follow the manual steps below, they give the same answer.

Why check this

Run this on every deploy that touches the rendering mode, and on any page that a framework upgrade moved between a server component and a client one. It is also the check that closes a crawlability investigation, because a page whose content is in the first response cannot fail for a client that does not run scripts.

The failure it prevents is the expensive one on this topic: markup that is present and correct, with the values inside it empty until hydration. Nothing looks wrong in a browser, nothing looks wrong in the Elements panel, and the price, the stock line or the review count is missing for every reader that does not execute JavaScript. That includes link preview fetchers, feed readers, text browsers, and any crawler whose render pass is deferred or skipped.

What this measures is your own server's output. It does not report what Google rendered or stored. Search Console URL Inspection answers that, on a verified property.

Prerequisites

import { createServer } from 'node:http';
const wrap = (title, main) => `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>${title}</title></head>
<body>${main}</body></html>`;
const routes = {
  '/ssr': wrap('Trail Runner 3', `<article><h1>Trail Runner 3</h1>
<p>A 240 g road shoe with an 8 mm drop, built for daily mileage.</p>
<p class="price">USD 129.00</p><p class="stock">In stock, 4 left</p></article>`),
  '/hydrate': wrap('Trail Runner 3', `<article><h1>Trail Runner 3</h1>
<p>A 240 g road shoe with an 8 mm drop, built for daily mileage.</p>
<p class="price"><span id="price"></span></p><p class="stock"><span id="stock"></span></p></article>
<script>document.getElementById('price').textContent = 'USD 129.00';
document.getElementById('stock').textContent = 'In stock, 4 left';</script>`),
};
createServer((req, res) => {
  const html = routes[req.url];
  res.writeHead(html ? 200 : 404, { 'content-type': 'text/html; charset=utf-8' });
  res.end(html ?? '<!doctype html><title>404</title>');
}).listen(8819, '127.0.0.1', () => console.log('ssr-server listening on 127.0.0.1:8819'));

Browser figures below are one capture on one machine, Chrome 152.0.7977.76.

Steps

  1. Step 1.

    Pull the headings and paragraphs straight out of a served response, on a page you know is server-rendered.

    curl -sS https://example.com/ | grep -oE '<h1>[^<]*|<p>[^<]*' | head -3
    
    <h1>Example Domain
    <p>This domain is for use in documentation examples without needing permission. Avoid use in operations.
    <p>

    The heading and the body text arrive in the first response. That is the shape a pass has.

  2. Step 2.

    Fetch the local route that renders on the server and look for the two values.

    curl -sS http://127.0.0.1:8819/ssr
    
    <!doctype html>
    <html lang="en"><head><meta charset="utf-8"><title>Trail Runner 3</title></head>
    <body><article><h1>Trail Runner 3</h1>
    <p>A 240 g road shoe with an 8 mm drop, built for daily mileage.</p>
    <p class="price">USD 129.00</p><p class="stock">In stock, 4 left</p></article></body></html>

    USD 129.00 and In stock, 4 left are in the bytes, inside their elements.

  3. Step 3.

    Fetch the hydration route and compare the same two elements.

    curl -sS http://127.0.0.1:8819/hydrate
    
    <!doctype html>
    <html lang="en"><head><meta charset="utf-8"><title>Trail Runner 3</title></head>
    <body><article><h1>Trail Runner 3</h1>
    <p>A 240 g road shoe with an 8 mm drop, built for daily mileage.</p>
    <p class="price"><span id="price"></span></p><p class="stock"><span id="stock"></span></p></article>
    <script>document.getElementById('price').textContent = 'USD 129.00';
    document.getElementById('stock').textContent = 'In stock, 4 left';</script></body></html>

    The structure survived and the values did not. <span id="price"></span> is an element with nothing in it. A structural check that asks whether .price exists passes this page.

  4. Step 4.

    Count the words each route serves, with scripts off.

    for r in ssr hydrate; do
      printf '/%-8s %2s words with scripts off\n' "$r" "$(node render.mjs http://127.0.0.1:8819/$r --text --nojs | wc -w)"
    done
    
    /ssr      23 words with scripts off
    /hydrate  17 words with scripts off

    Six words missing out of 23, from two pages built from the same template.

  5. Step 5.

    Name the missing six, by diffing the two scripts-off readings.

    diff <(node render.mjs http://127.0.0.1:8819/ssr --text --nojs) <(node render.mjs http://127.0.0.1:8819/hydrate --text --nojs)
    
    4,7d3
    < 
    < USD 129.00
    < 
    < In stock, 4 left

    The price and the stock line. Both are the fields a product page is indexed and previewed on, and both are the fields most likely to come from client state.

  6. Step 6.

    Repeat the diff with scripts on, to see why this defect survives review.

    diff <(node render.mjs http://127.0.0.1:8819/ssr --text) <(node render.mjs http://127.0.0.1:8819/hydrate --text) && echo 'identical after scripts run'
    
    identical after scripts run

    No output from diff at all. In a browser the broken page and the correct one are the same page, which is why this is caught on the wire or not at all.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | The page's words are in the curl body | The server renders them | Check the head tags the same way | | Elements present, values empty | Markup rendered on the server, data filled on the client | Move the data into the render, not into an effect that runs after mount | | An empty root element and nothing else | No server rendering at all | Go to the JavaScript rendering check and count what is missing | | Scripts-off word count far below scripts-on | Part of the page depends on hydration | Diff the two readings to get the list | | Content present but the title or canonical missing | Only the body is rendered on the server | Run the meta tag checker against the same URL |

Common mistakes

Sign: The page renders correctly in every browser, in DevTools and in a screenshot test, and a crawler still reports missing content.Cause: Step 6 produced no diff at all between a correct page and a broken one, because the browser runs the script that fills them. The Elements panel shows the DOM after hydration, never the response. Nothing you can see in a browser answers this question. Read the bytes, or load them with scripts disabled, as steps 3 to 5 do.
Sign: A selector-based check asserts that `.price` is in the served HTML, it passes, and the price is still missing.Cause: Step 3 serves a price paragraph whose only child is an empty span. The element is there and its text is not. An assertion on presence is not an assertion on content. Assert the text of the element, or a word count floor for the region, since both fail when hydration is the only thing filling it.
Sign: The body is server-rendered, so the page is signed off, and link previews stay blank.Cause: Head tags hydrate too. A title, a canonical or an og:image set by a script after mount is absent from the response, and most preview fetchers read the response and stop. Check the head with the same scripts-off reading you used for the body, or with the meta tag checker linked above.

What to check next

FAQ

How to check server side rendering without any tooling?

Load the page with scripts disabled and read what is left. The DevTools command menu, Ctrl+Shift+P, has a Disable JavaScript entry, and --nojs above reaches the same switch through the protocol. What remains on screen is what the server sent, which is enough for a single page.

Does view source prove server side rendering?

It proves the content is in the response, which is the question. Read it for the values, not for the structure: step 3 shows correct markup whose two values are empty. Search the source for the exact price or heading you expect, not for the class name.

Why does the page look complete in DevTools but not to a crawler?

The Elements panel renders the live DOM, after scripts have run and hydration has finished. A crawler that does not run scripts, or defers the render pass, sees the response instead. The two differ by exactly the six words step 5 listed.

Is a hydration mismatch the same problem?

No. A mismatch is the client rendering something different from the server and replacing it. This is the server rendering nothing at all for that element. The wire check finds both, since an empty element in the response is visible either way.

Verified

Verified by Maks Vernycurl 8.21.0node 22.23.2Chrome 152.0.7977.76

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate8 minpublished updated Maks Verny