Debugging & Diagnostics for Headless Rendering

In a headless stack the HTML a crawler receives is produced by a build step, an edge function, or a hydration boundary — any of which can silently drop content or slow first paint. This section is a repeatable diagnostic workflow that catches rendering and Core Web Vitals regressions in the deploy pipeline, before they reach the index.

Prerequisites

Before wiring diagnostics into your pipeline, confirm these are in place:

  • A CI runner (GitHub Actions, GitLab CI, or similar) that can run on every pull request
  • @lhci/cli 0.13+ installed as a dev dependency
  • A preview deployment URL per pull request (Vercel, Netlify, or Cloudflare Pages preview) so Lighthouse tests the real build, not a local dev server
  • Chrome headless available in CI — the lhci Docker image or browser-actions/setup-chrome provides it
  • Node 18+ and access to your production Core Web Vitals baseline for comparison

A working understanding of ISR vs SSG vs CSR routing is assumed — the render mode of each route determines what a crawler sees before hydration, which is exactly what these diagnostics verify.

The Rendering Diagnostic Gate

The diagram below shows where the diagnostic step sits in the pipeline and how it gates promotion.

Rendering diagnostic gate in the deploy pipeline A pipeline diagram: build feeds a preview deployment, Lighthouse CI runs assertions, and a decision gate promotes the build on pass or blocks it on fail. Build Deploy preview per pull request Lighthouse CI assert budgets gate Promote Block pass fail

Step-by-Step Implementation Workflow

Step 1 — Add Lighthouse CI to the pipeline

Install the runner and add a collect step that targets the preview deployment URL. Running against the deployed preview — not a local server — measures the same edge caching and network path a crawler experiences.

npm install --save-dev @lhci/cli
# In CI, after the preview URL is known:
npx lhci autorun \
  --collect.url="$PREVIEW_URL" \
  --collect.numberOfRuns=3

Step 2 — Set assertion budgets

Declare budgets in lighthouserc.js. Median of three runs smooths variance; fail on the metrics that map to Core Web Vitals plus the SEO category score.

// lighthouserc.js
module.exports = {
  ci: {
    assert: {
      assertions: {
        'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
        'total-blocking-time': ['error', { maxNumericValue: 200 }],
        'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
        'categories:seo': ['error', { minScore: 1 }],
      },
    },
  },
};

Step 3 — Detect render-blocking resources

Turn the render-blocking-resources audit into a hard assertion so a newly-introduced synchronous script fails the build rather than merely warning.

// add to the assertions block above
'render-blocking-resources': ['error', { maxLength: 0 }],
'unused-javascript': ['warn', { maxNumericValue: 40000 }],

Step 4 — Verify server HTML contains content

Lighthouse renders with JavaScript, so it cannot tell you whether content exists in the server HTML. Add a scripts-disabled fetch that asserts the main content and canonical are present before hydration.

# Fetch raw HTML (no JS execution) and assert main content is present
html=$(curl -s "$PREVIEW_URL")
echo "$html" | grep -q '<h1' || { echo "No server-rendered H1"; exit 1; }
echo "$html" | grep -qi 'rel="canonical"' || { echo "No canonical in HTML"; exit 1; }

Step 5 — Block promotion on regression

Gate the promote job on the diagnostic job. In GitHub Actions, a failed assertion returns a non-zero exit code, so a dependent job never runs.

# .github/workflows/deploy.yml
jobs:
  diagnostics:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npx lhci autorun --collect.url=${{ env.PREVIEW_URL }}
  promote:
    needs: diagnostics   # only runs if diagnostics passed
    runs-on: ubuntu-latest
    steps:
      - run: ./scripts/promote-to-production.sh

Framework-Specific Code Examples

The diagnostic gate is the same in every framework; only the place the fix lands differs.

One question, three places to fix it The question of whether a route rendered on the server, resolving to segment exports, route rules, or page flags depending on the framework. did it render on the server? Next.js — segment exports Nuxt — routeRules SvelteKit — page flags

The diagnostic that most often fails is Step 4 — content missing from server HTML. How you confirm server rendering differs per framework.

Next.js App Router

# Inspect the built output: a statically rendered route emits HTML at build time
next build
grep -rl '<h1' .next/server/app | head
# At runtime, confirm the deployed route serves content without JS
curl -s "$PREVIEW_URL/blog/post" | grep -c '<article'

SEO impact: A route that unexpectedly opts into dynamic rendering (via cookies(), headers(), or searchParams) ships an empty shell to crawlers on cold requests. Confirming content in .next/server/app proves the route was statically rendered.

Validation: curl the deployed route and assert the <article> count is greater than zero without a browser.

SvelteKit

# A prerendered route writes a static .html file containing content
vite build
find .svelte-kit/output/prerendered -name '*.html' \
  -exec grep -L '<h1' {} \;   # lists files MISSING an H1 — should be empty

SEO impact: With export const prerender = true, SvelteKit emits static HTML with full content and head tags. A route left as client-side-rendered ships an empty <div id="svelte">, which crawlers may index as thin.

Validation: The grep -L list must be empty — every prerendered file contains an <h1>.

Nuxt 3

# Nitro server render: confirm content is in the initial payload, not only hydrated
curl -s "$PREVIEW_URL/products/item" \
  | grep -o 'data-server-rendered="true"'
curl -s "$PREVIEW_URL/products/item" | grep -c '<h1'

SEO impact: Nuxt routes set to ssr: false or a client-only routeRules mode return an empty app root. Checking for server-rendered markers confirms Nitro produced content-bearing HTML.

Validation: The <h1> count must be non-zero and the server-rendered marker present in the raw response.

HTTP Headers & CDN Directives Reference

Header Required value Rationale
Server-Timing render;dur=<ms> Surfaces server render duration in the diagnostic run, separating slow origin from slow network
Cache-Control public, max-age=0, s-maxage=86400, stale-while-revalidate=59 Confirms the preview is served from the edge, so Lighthouse measures cached TTFB like a crawler
Content-Encoding br or gzip Missing compression inflates transfer size and Total Blocking Time on script-heavy pages
X-Robots-Tag index, follow Ensures a diagnosed route is not accidentally blocked from indexing at the header layer
Vary Accept-Encoding Prevents a CDN from serving an uncompressed variant that skews render-blocking measurements

Configure these in line with edge caching behavior for SEO so the preview environment reflects production timings rather than an uncached origin.

Validation Protocol

Local Lighthouse smoke run

# Reproduce a CI failure locally against the same preview URL
npx lhci collect --url="$PREVIEW_URL" --numberOfRuns=3
npx lhci assert --config=lighthouserc.js

Scripts-disabled crawl check

# What a non-rendering crawl sees: strip nothing, execute nothing
curl -s "$PREVIEW_URL/blog/post" > raw.html
wc -c raw.html                       # near-empty file signals client-only rendering
grep -c '<p' raw.html                # paragraph count in server HTML

History-backed regression check

# Upload results to compare against a rolling baseline instead of a fixed number
npx lhci upload --target=temporary-public-storage

Troubleshooting

Two checks together separate the two failure classes, and running only one of them is why rendering problems are so often misdiagnosed.

Two checks that separate slow from absent A Lighthouse run measuring speed and a JavaScript-free fetch measuring presence, together distinguishing a slow page from an empty one. Lighthouse run renders — measures speed fetch without JavaScript measures presence catches slow pages catches empty ones A client-rendered page can score well on the left and fail entirely on the right
Symptom Root cause Fix
Lighthouse passes but indexed content is thin Page renders client-side; Lighthouse executes JS, crawler snapshot did not Add the scripts-disabled check from Step 4; move main content to server rendering
LCP regression only in CI, not locally Local dev server skips edge cache and compression Always test the deployed preview URL, not localhost
render-blocking-resources flags a vendor script Synchronous third-party tag in <head> Move to defer/async or load after hydration — see diagnosing render-blocking JavaScript in headless frontends
Assertions flap between pass and fail Single run captures network variance Set numberOfRuns: 3 and assert on the median
Job passes despite a broken route Lighthouse only tested the home page Add every critical template URL to collect.url
Preview URL 401s in CI Preview deployment is password-protected Pass a bypass token or use a public preview channel for the diagnostic job

Pages in This Section

Building a diagnostic habit rather than a diagnostic script

The tooling in this section is straightforward; what makes it effective is running it on a schedule rather than in response to an incident. A rendering regression that is caught by a nightly check is a small fix, while the same regression caught six weeks later by a traffic report is an investigation that starts with the question “what changed?” and no evidence to answer it.

The habit worth establishing is a small, fixed set of measurements taken at the same cadence: the critical-element diff on each template, a Lighthouse run against the same representative URLs, and the crawler cache-hit ratio. All three are cheap, all three produce a number, and the number is only interesting when it moves. Storing the history is what turns them from checks into diagnostics, because a value in isolation rarely identifies a cause and a change in a value almost always does.

It is equally important to be explicit about what these measurements do not cover. They say nothing about content quality, about whether the page satisfies the query, or about whether the URL should exist at all. Treating a green diagnostic dashboard as evidence that SEO is fine is a common and expensive mistake — the dashboard confirms that the technical layer is not the constraint, which is useful precisely because it directs attention elsewhere.

When the diagnosis is the deployment pipeline

A recurring pattern in decoupled stacks is that the rendering problem is not in the rendering code at all. A route renders correctly in development and in preview, and incorrectly in production, because production runs a different adapter, a different Node version, or an edge runtime with a different set of available APIs.

When the same code produces different HTML in two environments, stop debugging the code and start diffing the environments. The three variables worth checking first are the runtime target, the environment variables available at render time, and whether the response passed through a transform at the edge. One of those three explains the large majority of environment-specific rendering differences, and none of them is visible from the application source.

Two numbers worth watching continuously

Most of the diagnostics on this page are run in response to a question. Two are worth running continuously regardless, because they are leading indicators for problems that are expensive once they arrive.

The first is the size of the critical-element diff per template — the count of head tags, structured-data blocks, and internal links present after rendering but absent from the server response. It should be zero, it changes only when someone moves code across the client boundary, and it detects that move on the day it happens rather than in the quarter’s traffic report.

The second is client JavaScript bytes per route. It has no direct SEO effect, but it is the single best predictor of interaction-latency regressions and it moves gradually rather than suddenly, which makes a trend far more informative than any individual measurement. Both numbers come out of the build for free; the only work is recording them.

Frequently Asked Questions

How do I fail a deploy on a Core Web Vitals regression? Run Lighthouse CI against the preview URL in the same pipeline that gates promotion, and declare assertions for LCP, Total Blocking Time, and CLS. When an assertion breaches, lhci assert exits non-zero, the job fails, and the dependent promote job never runs. Store history so you also catch gradual drift against a rolling baseline, not just an absolute threshold.

How do I detect render-blocking JavaScript? Turn the Lighthouse render-blocking-resources audit into a hard assertion, then confirm each finding by loading the page with JavaScript disabled. Main content that disappears without JS is hydration-dependent. The dedicated guide on diagnosing render-blocking JavaScript in headless frontends walks through splitting and deferring the offending bundles.

Can Lighthouse CI catch CSR-only content? Not on its own — Lighthouse renders with JavaScript, so a purely client-rendered page can still score well. Pair it with the scripts-disabled fetch in Step 4 that asserts the main content exists in the raw server HTML. Together they fail the build both when performance regresses and when server rendering silently breaks.

Is this pipeline different from crawl and indexation debugging? Yes. This section diagnoses how a page renders — whether content and metadata reach the crawler quickly and completely. Diagnosing whether crawled URLs actually get indexed is a routing concern covered under debugging and diagnostics for headless routing. Rendering problems usually surface first as thin or slow pages; indexation problems surface as coverage gaps.


Part of: Headless Architecture & Rendering Strategy Fundamentals

Related