Comparing Rendered vs Source HTML in CI

The cheapest rendering regression is the one that never ships. A CI step that renders your app and fails the build when the rendered HTML is missing required content moves the entire class of β€œthe page went blank” failures from a production incident to a red pull request. This guide is part of monitoring JavaScript SEO in production, and it is the fastest of the three monitoring layers.

Problem Statement

For a client-rendered app, the HTML your server returns is a decoy. View-source shows an empty root container and a script tag; the content that Googlebot indexes only appears after JavaScript executes and populates the DOM. That gap is where regressions hide. A refactor that moves the h1 behind a lazy import, a data hook that now throws when a prop is undefined, or a hydration mismatch that unmounts the tree β€” each one leaves the source HTML unchanged and empties the rendered HTML. Your tests pass because they mount a component in isolation; the route as a whole renders nothing.

Comparing rendered against source HTML in CI closes this gap by asserting on the state that actually matters: the DOM after the client render, the same DOM the Web Rendering Service captures. If the render does not produce the title, heading, canonical, and body text an indexable page needs, the build goes red.

A CI gate comparing source HTML to rendered HTML The pipeline serves the empty source shell, renders it in headless Chromium, asserts required tags exist, and passes the deploy only when the rendered HTML contains them. Source HTML empty shell Render in CI headless Chromium Assert tags h1, title, canonical Pass deploy Fail block merge
The gate renders the empty shell and only passes the deploy when the rendered HTML carries the tags an indexable page needs.

Step-by-step fix

  1. Serve the built app inside the pipeline. After the build, start the production preview server (or serve the static output) on a local port so the render check hits the same bundle you are about to deploy.

  2. Render one URL per template in headless Chromium. Use Playwright or Puppeteer to open each representative route, wait for network idle, and read the rendered DOM. One URL per template keeps the check fast and its cost flat as the site grows.

  3. Assert the required indexable elements exist. Check for exactly one non-empty h1, a non-empty title, a meta description, a self-referencing canonical, and a body-text length above a floor. Collect every failure rather than throwing on the first, so one run reports all the problems.

  4. Fail the build on any missing element. Exit non-zero when an assertion fails and print which element is missing on which route. A required status check on the pull request blocks the merge until the render is fixed.

  5. Run it on every pull request. Wire the job into the pipeline as a required check. Now a regression that empties a route cannot merge, and the developer who introduced it sees exactly what broke in the same review.

The indexable contract a CI render check asserts Five structural elements the rendered DOM must contain for a route to be indexable: one non-empty h1, a title, a meta description, a self-referencing canonical, and body text above a floor. The rendered DOM must contain exactly one non-empty h1 a non-empty title a meta description present a self-referencing canonical main body text above a length floor
The gate passes only when all five of these survive the client render; any one missing turns the build red and names the route.

Validation

The check below renders each route and asserts the indexable contract. Run it locally against a deliberately broken build to confirm it fails loudly before you trust it as a gate.

// ci-render-check.mjs β€” fail the build when a rendered route lacks required tags.
import { chromium } from 'playwright';

const ROUTES = [
  'http://localhost:4173/',
  'http://localhost:4173/blog/first-post/',
  'http://localhost:4173/products/example-item/',
];

const browser = await chromium.launch();
const page = await browser.newPage();
const problems = [];

for (const url of ROUTES) {
  await page.goto(url, { waitUntil: 'networkidle' });
  const r = await page.evaluate(() => ({
    h1Count: document.querySelectorAll('h1').length,
    title: document.title.trim(),
    canonical: document.querySelector('link[rel=canonical]')?.href ?? '',
    description: document.querySelector('meta[name=description]')?.content ?? '',
    textLen: (document.querySelector('main')?.innerText ?? '').trim().length,
  }));
  if (r.h1Count !== 1) problems.push(`${url}: expected 1 h1, found ${r.h1Count}`);
  if (r.title.length < 5) problems.push(`${url}: empty or too-short title`);
  if (!r.canonical) problems.push(`${url}: missing canonical`);
  if (!r.description) problems.push(`${url}: missing meta description`);
  if (r.textLen < 200) problems.push(`${url}: main text ${r.textLen} chars below floor`);
}

await browser.close();
console.assert(problems.length === 0, `RENDER CHECK FAILED:\n${problems.join('\n')}`);
if (problems.length) process.exit(1);
console.log(`render check passed for ${ROUTES.length} routes`);

A green run means every template still renders its indexable content; a red run names the route and the missing element. Add the script as a required job so the exit code gates the merge.

View-source shell versus the rendered DOM The served source HTML is an empty root container, while the DOM after JavaScript runs carries the h1, title, canonical, and body text a CI render check asserts on. Served source HTML div id=root no content yet 0 indexable tags Rendered DOM after JS h1 Β· title Β· meta description canonical present body text above floor 5 indexable tags JS runs
Reading the served shell sees zero indexable tags; only the rendered DOM proves the route produces the content Googlebot indexes.

Common Pitfalls

  • Asserting against source HTML. Reading the served HTML instead of the rendered DOM checks the empty shell and passes even when the render is completely broken. Always assert after the client render completes.
  • Testing components, not routes. Unit tests mount a component with props supplied; they cannot catch a route that renders nothing because its data hook threw. The render check exercises the whole page.
  • Throwing on the first failure. Stopping at the first missing tag hides the rest. Collect all problems and report them together so one run fixes the whole set.
  • Asserting on volatile content. Checking exact prices or timestamps makes the gate flaky and gets it disabled. Assert on structural, indexable elements only.
  • Skipping the check on β€œsmall” PRs. Rendering regressions often arrive in innocuous-looking refactors. Make the check required on every pull request, not opt-in.
Five pitfalls that let a broken render pass the CI gate Five common mistakes β€” asserting against source HTML, testing components not routes, throwing on the first failure, asserting on volatile content, and skipping the check on small pull requests β€” each let a broken or under-checked render slip past the gate. Five ways the render gate misses a broken route Assert against source HTML checks the empty shell β€” always green Test components, not routes a thrown data hook is never exercised Throw on the first failure the other broken routes stay hidden Assert on volatile content flaky runs get the gate disabled Skip on "small" PRs regressions ride in innocuous refactors Broken render slips through gate green, route empty
Each of these mistakes turns the gate green while the route is actually broken, so a rendering regression merges unnoticed β€” assert on the rendered DOM, collect every failure, and require the check everywhere.

Frequently Asked Questions

Why compare rendered HTML to source HTML in CI?

The source HTML of a client-rendered app is just an empty shell; the content only exists after JavaScript runs. Comparing the two in CI proves that the render actually produces the title, headings, canonical, and body text that Googlebot indexes, so a regression that empties a route fails the build instead of shipping.

What should a CI render check assert on?

Assert on the structural, indexable elements that must survive the render: exactly one non-empty h1, a non-empty title, a meta description, a self-referencing canonical, and a minimum amount of main body text. Avoid asserting on volatile values like prices or timestamps, which change legitimately and would make the check flaky.

Does a CI render check slow down the pipeline?

Minimally. Rendering a handful of representative routes in headless Chromium takes seconds, and it runs in parallel with other jobs. Because it checks one URL per template rather than the whole site, the cost stays flat as the site grows while still catching template-level render failures before they deploy.

← Back to Monitoring JavaScript SEO in Production