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 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

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

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