# React server rendering lab and production handoff

Pack version 1.0 · 16 September 2026. Original lab version 1.0.

Run the fictional HarborDesk lab to compare initial HTML and React hydration, then record the separate checks required for a real application. This is a local HTTP and jsdom exercise, not a production server or evidence of search visibility.

Guide: https://rankecho.io/resources/fix/server-rendering-react

Download the runnable file: https://rankecho.io/downloads/react-server-rendering-lab-v1.mjs

Save the downloaded file in an empty directory and rename it to example.mjs to match the original commands below. Installing dependencies requires npm registry access; the lab itself contacts only its loopback server. Keep the original file until you have compared its results.

Copy the complete lab into an empty directory as example.mjs, install the exact versions below, and run it. It compares localhost client-only and server-rendered responses, then exercises React in jsdom.

Executed local HTTP and DOM lab; production acceptance follows below.

## When is server rendering the right React SEO fix?

Start with a verified delivery gap on an intended public page: the buyer answer exists in the browser-rendered DOM but is absent from the initial response. Use the paired-capture diagnosis for that decision. Server rendering is not a universal requirement, and Google can render JavaScript; the repair is justified here by the page's confirmed initial-HTML acceptance goal.

Do not use this change to expose a signed-in workspace, rewrite an approved canonical, or repair weak copy. Preserve those owners. If the application uses Next.js, use its rendering guide because its routing, data, metadata, and build boundaries differ.

- [Verify an initial-versus-rendered delivery gap](https://rankecho.io/resources/find/issues/javascript-only-evidence)
- [Use the Next.js-specific rendering guide](https://rankecho.io/resources/fix/server-rendering-nextjs)
- [Keep exclusions and canonicals with their owner](https://rankecho.io/resources/find/issues/noindex-canonical-conflict)
- [Keep buyer-answer copy with its owner](https://rankecho.io/resources/fix/product-page-for-ai-search)
- [Review Google's JavaScript SEO guidance](https://developers.google.com/search/docs/crawling-indexing/javascript/javascript-seo-basics)

## How do you run the lab?

Use an empty directory and save the entire artifact as example.mjs. The repository fixture has a lockfile for CI; these commands pin its direct dependencies for a fresh reproduction.

The receipt used Node 24.19.0, React and ReactDOM 19.3.0, jsdom 30.0.1, and esbuild 0.28.1. Record runtime differences before adapting the result.

- Run npm init -y
- Run npm install --save-exact react@19.3.0 react-dom@19.3.0 jsdom@30.0.1 esbuild@0.28.1
- Run node example.mjs


## Where is the React server and hydration boundary?

The Node HTTP handler owns the route result. In server mode, renderToPipeableStream receives the same Page tree later passed to hydrateRoot. For this tiny, preloaded, synchronous lab, onAllReady starts piping after the tree is complete; a PassThrough buffers the markup before res.end sends the document. That deterministic choice is specific to the reproduction, not a general streaming or performance recommendation.

esbuild bundles the trusted client component into jsdom's isolated window, where real createRoot and hydrateRoot run. A wrapper useEffect signals mount, and flushSync makes the click observable to the test; these are synchronization details, not production performance advice. A real app still needs its route/data boundary and browser bootstrap. The lab delivers no browser asset.

- [Review React's Node streaming API](https://react.dev/reference/react-dom/server/renderToPipeableStream)
- [Review React's hydration contract](https://react.dev/reference/react-dom/client/hydrateRoot)
- [Review the flushSync test helper and warning](https://react.dev/reference/react-dom/flushSync)
- [Review the effect used to signal test mount](https://react.dev/reference/react/useEffect)
- [Review esbuild's in-memory bundle API](https://esbuild.github.io/api/)

## What did the local receipt establish?

Actual localhost HTTP GETs preceded the jsdom simulations. Client mode returned a raw 200 document with an empty #root; createRoot produced the HarborDesk answer and limitation. Server mode put both in raw HTML. hydrateRoot returned no recoverable errors, kept both answers, and the button expanded.

The canonical is fictional: https://harbordesk.example/features/csv-export. Only /docs/csv-export link markup was checked, not its destination. Fixed /workspace and /missing controls retained 401/noindex and 404/noindex. A synthetic Googlebot string received identical bytes; restoring client mode returned identical response fields. Neither tested a verified bot or production rollback.

Major limits: this receipt covers localhost responses and a jsdom DOM simulation only. It does not test browser asset loading, CSS or layout, timing, a real browser, production delivery, async data or Suspense, authentication implementation, CDN behavior, request isolation, the error branch, crawling, indexing, citations, traffic, or conversions. jsdom does not provide visual browser rendering.

| Case | Initial response | Later check |
| --- | --- | --- |
| Client-only baseline | 200; raw #root was empty | createRoot produced the answer and limitation paragraphs in jsdom. |
| Server-rendered public route | 200; raw #root contained both paragraphs | hydrateRoot reported no recoverable errors; the button expanded and both answers stayed unchanged. |
| Protected and missing routes | /workspace stayed 401 + noindex; /missing stayed 404 + noindex | Both response objects were unchanged after server rendering was enabled. |
| User-Agent and rollback controls | Synthetic Googlebot HTML equaled the ordinary response; client mode restored the baseline response | A string and local response comparison only; no verified bot or production rollback ran. |

- [Review jsdom's runtime and visual limits](https://github.com/jsdom/jsdom)

## What must a production handoff accept?

Give engineering one URL, the verified missing answer, before and target responses, route and bootstrap owners, and protected controls. Reproduce the difference, then apply this table to the real framework instead of copying the lab server.

| Acceptance area | Required production evidence |
| --- | --- |
| Route and build | Use the current framework, route, shell, data loader, build, and client assets; do not substitute this standalone server. |
| Initial response | A signed-out GET returns the approved status, canonical, directives, answer, limitation, and documentation link in raw HTML. |
| Hydration | The real bootstrap hydrates the same tree and data without a mismatch, preserves the answer, and passes interaction checks. |
| Status and access | Authentication remains effective; real 401, 404, redirect, noindex, and error branches retain intended bodies and headers. |
| Cache and isolation | Concurrent requests, personalization, locale, cookies, and CDN/cache variants cannot leak markup; define rollback and restoration. |
| Browser and async | Test async/Suspense, assets, CSS/layout, accessibility, timing, logging, and supported browsers under the chosen streaming policy. |
| Later evidence | Check crawl and indexed state separately; record search, citation, referral, and conversion measures without assigning causation. |

- [Inspect a sample bounded fix](https://rankecho.io/sample-fix)
- [Measure later results separately](https://rankecho.io/resources/prove/citation-to-session-to-conversion)

## Does React server rendering guarantee search or AI visibility?

No. Google's AI features use ordinary Search index and snippet eligibility and require no special AI schema or text file. Correct initial HTML meets one delivery criterion; it does not show crawling, indexing, selection, citation, or a visit.

Verify production delivery, then record indexed state, search, citations, referrals, product actions, accounts, and conversions as separate dated observations. Sequence after deployment does not establish cause.

- [Review Google's AI-feature eligibility guidance](https://developers.google.com/search/docs/appearance/ai-features)
- [Separate citations, sessions, and conversions](https://rankecho.io/resources/prove/citation-to-session-to-conversion)

## Where does RankEcho fit after the handoff?

The lab works without an account. The paid Fix Engine can organize a bounded correction for human review and manual shipment; it does not deploy React or server changes, provide production routing/build/bootstrap, connect natively to Search Console or GA4, or request indexing.

Free audits check Perplexity and Gemini once per prompt. Paid and trialing accounts add ChatGPT, Claude, and Google AI Overviews when configured for the account, for up to 5 engines. RankEcho does not currently run Microsoft Copilot checks. RankEcho does not guarantee indexing, rankings, citations, traffic, or conversions.

- [Inspect the sample fix](https://rankecho.io/sample-fix)
- [Evaluate the Fix Engine](https://rankecho.io/product/fix-engine)

## Your production handoff

Complete these fields from your real application. Leave unperformed checks explicitly pending; the local lab does not complete this acceptance record.

- Approved public URL and page owner: [Enter your response]
- Missing buyer answer and paired before captures: [Enter your response]
- Target response, route owner and client bootstrap owner: [Enter your response]
- Protected controls and intended status or access: [Enter your response]
- Proposed change, approver and implementation owner: [Enter your response]
- Rollback operator, trigger and restoration evidence: [Enter your response]

| Acceptance area | Owner | Evidence location and result |
| --- | --- | --- |
| Route and build | [Assign owner] | [Pending evidence] |
| Initial response | [Assign owner] | [Pending evidence] |
| Hydration | [Assign owner] | [Pending evidence] |
| Status and access | [Assign owner] | [Pending evidence] |
| Cache and isolation | [Assign owner] | [Pending evidence] |
| Browser and async | [Assign owner] | [Pending evidence] |
| Later evidence | [Assign owner] | [Pending evidence] |

Inspect a sample fix: https://rankecho.io/sample-fix. Evaluate the Fix Engine: https://rankecho.io/product/fix-engine for a bounded change with human review and manual shipment. Full-page writing is a separate content add-on: https://rankecho.io/pricing/content, not included with the visibility subscription.

## Complete original runnable lab

Save only the code below as example.mjs if using this Markdown file instead of the separate download. It is identical to the original Copy artifact.

```javascript
// Fictional local delivery lab. No production site or search engine is contacted.
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { PassThrough } from 'node:stream';
import React, { createElement as h, useState } from 'react';
import { renderToPipeableStream } from 'react-dom/server';
import { build } from 'esbuild';
import { fileURLToPath } from 'node:url';
import { JSDOM } from 'jsdom';

const canonical = 'https://harbordesk.example/features/csv-export';
const answer = 'HarborDesk workspace owners can export completed tasks as CSV.';
const limit = 'Deleted tasks are excluded; members must ask a workspace owner.';
function Page() {
  const [expanded, setExpanded] = useState(false);
  return h('main', null,
    h('h1', null, 'HarborDesk CSV export'),
    h('p', { id: 'answer' }, answer),
    h('p', { id: 'limit' }, limit),
    h('a', { href: '/docs/csv-export' }, 'Read export instructions'),
    h('button', { onClick: () => setExpanded(!expanded), 'aria-expanded': expanded }, 'Show example'),
    expanded ? h('p', { id: 'example' }, 'Example columns: task, owner, completion date.') : null);
}
function render() {
  return new Promise((resolve, reject) => {
    const output = new PassThrough();
    let html = '';
    output.setEncoding('utf8');
    output.on('data', chunk => { html += chunk; });
    output.on('end', () => resolve(html));
    output.on('error', reject);
    const stream = renderToPipeableStream(h(Page), {
      onAllReady() { stream.pipe(output); },
      onShellError: reject,
      onError: reject,
    });
  });
}
// Bundle this trusted local component for jsdom's separate JavaScript context.
const client = await build({
  stdin: { contents: `
    import { createElement as h, useState, useEffect } from 'react';
    import { flushSync } from 'react-dom';
    import { createRoot, hydrateRoot } from 'react-dom/client';
    const answer = ${JSON.stringify(answer)}, limit = ${JSON.stringify(limit)};
    ${Page.toString()}
    let root;
    window.lab = {
      errors: [],
      async mount(hydrate) {
        await new Promise(resolve => {
          function Mounted() { useEffect(resolve, []); return h(Page); }
          const target = document.querySelector('#root');
          root = hydrate
            ? hydrateRoot(target, h(Mounted), { onRecoverableError: error => window.lab.errors.push(error.message) })
            : createRoot(target);
          if (!hydrate) root.render(h(Mounted));
        });
      },
      click() { flushSync(() => document.querySelector('button').click()); },
      close() { root.unmount(); }
    };
  `, resolveDir: fileURLToPath(new URL('.', import.meta.url)) },
  bundle: true, write: false, platform: 'browser', format: 'iife',
  define: { 'process.env.NODE_ENV': '"development"' },
});
const makeDom = html => {
  const dom = new JSDOM(html, { url: canonical, runScripts: 'outside-only' });
  dom.window.eval(client.outputFiles[0].text);
  return dom;
};
let mode = 'client';
const server = createServer(async (req, res) => {
  const path = new URL(req.url, 'http://localhost').pathname;
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
  if (path !== '/features/csv-export') {
    res.statusCode = path === '/workspace' ? 401 : 404;
    res.setHeader('X-Robots-Tag', 'noindex');
    return res.end(path === '/workspace' ? 'Sign in required' : 'Not found');
  }
  try {
    const body = mode === 'server' ? await render() : '';
    res.end(`<!doctype html><html lang="en"><head><title>HarborDesk CSV export</title><link rel="canonical" href="${canonical}"></head><body><div id="root">${body}</div></body></html>`);
  } catch {
    res.statusCode = 503;
    res.setHeader('X-Robots-Tag', 'noindex');
    res.end('Temporarily unavailable');
  }
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const origin = `http://127.0.0.1:${server.address().port}`;
const read = async (path, userAgent = 'delivery-lab') => {
  const response = await fetch(origin + path, { headers: { 'User-Agent': userAgent } });
  return { status: response.status, robots: response.headers.get('x-robots-tag'), html: await response.text() };
};
const snapshot = doc => ['#answer', '#limit'].map(selector => doc.querySelector(selector)?.textContent ?? null);
const expected = [answer, limit];
const report = { react: React.version, dom: 'jsdom 30.0.1', bundler: 'esbuild 0.28.1', node: process.version };
try {
  const before = await read('/features/csv-export');
  const beforeDom = makeDom(before.html);
  assert.deepEqual(snapshot(beforeDom.window.document), [null, null]);
  // This is a DOM simulation, not a browser or crawler test.
  await beforeDom.window.lab.mount(false);
  assert.deepEqual(snapshot(beforeDom.window.document), expected);
  await beforeDom.window.lab.close();
  beforeDom.window.close();
  const excludedBefore = await read('/workspace');
  const missingBefore = await read('/missing');
  mode = 'server';
  const after = await read('/features/csv-export');
  assert.equal(after.status, 200);
  assert.equal(after.robots, null);
  const afterDom = makeDom(after.html);
  const document = afterDom.window.document;
  assert.deepEqual(snapshot(document), expected);
  assert.equal(document.querySelector('link[rel="canonical"]').href, canonical);
  assert.equal(document.querySelector('meta[name="robots"]'), null);
  assert.equal(document.querySelector('a').getAttribute('href'), '/docs/csv-export');
  await afterDom.window.lab.mount(true);
  assert.deepEqual(snapshot(document), expected);
  const errors = Array.from(afterDom.window.lab.errors);
  assert.deepEqual(errors, []);
  await afterDom.window.lab.click();
  assert.equal(document.querySelector('button').getAttribute('aria-expanded'), 'true');
  assert.equal(document.querySelector('#example').textContent, 'Example columns: task, owner, completion date.');
  assert.deepEqual(snapshot(document), expected);
  assert.deepEqual(await read('/workspace'), excludedBefore);
  assert.deepEqual(await read('/missing'), missingBefore);
  assert.equal(excludedBefore.status, 401);
  assert.equal(missingBefore.status, 404);
  assert.equal((await read('/features/csv-export', 'Googlebot')).html, after.html);
  mode = 'client';
  assert.deepEqual(await read('/features/csv-export'), before);
  await afterDom.window.lab.close();
  afterDom.window.close();
  Object.assign(report, { beforeInitial: [null, null], beforeClientDom: expected, afterInitial: expected, afterHydration: expected, recoverableErrors: errors, buttonExpanded: true, excludedStatus: 401, missingStatus: 404, userAgentBytesEqual: true, rollbackBytesEqual: true, publicStatus: after.status, canonical, publicHeaderRobots: after.robots, publicMetaRobots: null });
  console.log(JSON.stringify(report, null, 2));
} finally {
  await new Promise(resolve => server.close(resolve));
}
```
