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.
Step-by-step fix
-
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.
-
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.
-
Assert the required indexable elements exist. Check for exactly one non-empty
h1, a non-emptytitle, 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. -
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.
-
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.
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.
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.
Related
- Detecting rendering regressions with synthetic crawls β the production-side counterpart that catches data-driven failures.
- Catching SEO regressions with Lighthouse CI β a complementary CI gate on performance and SEO scores.
- Why Google indexes blank pages for React apps β the failure this gate prevents shipping.
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