TL;DR
Lighthouse CI (LHCI) runs a performance audit on every pull request and blocks merge when LCP exceeds 2.5 s, CLS exceeds 0.1, or Performance score drops below 0.85. For a Next.js 15 project on Node 22 with build node scripts/build.cjs and lint eslint, it is the cheapest insurance against a "small CSS change" that breaks SEO and Google Ads conversions a week later. Below: a working lighthouserc.json, GitHub Actions workflow, and the deliberate tradeoff GA4 afterInteractive vs Ads tags lazyOnload.
Who this is for
- Next.js / React teams deploying to Vercel or self-hosted
- Business sites where marketing and Ads depend on fast LCP
- Companies without dedicated performance QA — LHCI is a bot that does not forget mobile
- Developers tired of manual Lighthouse once a quarter "when quality happens to be OK"
Keywords (SEO)
lighthouse ci nextjs, automated performance audit, github actions lighthouse, core web vitals ci, lighthouserc.json, performance regression pull request
Why a one-off audit is not enough
Every PR can introduce:
- a new analytics script without
strategy="lazyOnload", - a hero image without
width/height→ CLS spike, - importing a whole icon library instead of tree-shaking,
- a font without
display: swap, - a client component heavier than the previous Server Component.
Lighthouse on next dev locally often lies — different bundling, no production optimizations, different cache. CI on next build + next start artifact is closer to what users and Google see.
Reference stack (DevStudio / Next.js 15)
| Element | Value |
|---|---|
| Framework | Next.js 15.5.x |
| Node | 22.x (engines in package.json) |
| Build | npm run build → node scripts/build.cjs |
| Lint | npm run lint → eslint |
| Production start | npm run start → next start |
| Analytics | GA4 afterInteractive, Google Ads lazyOnload |
This tag split matters in LHCI: a PR adding another afterInteractive script may pass functionally but drop Performance — exactly what the pipeline should catch.
Thresholds worth enforcing (mobile)
| Metric | LHCI threshold | Why |
|---|---|---|
| LCP | ≤ 2500 ms | Hero, first impression, SEO |
| CLS | ≤ 0.1 | Jumping form = fewer leads |
| INP | ≤ 200 ms (optional in assert) | Menu, chatbot, interactions |
| Performance score | ≥ 0.85 | Landing page quality signal (Ads, Search) |
Thresholds are deliberately strict for a marketing site — a SaaS dashboard app may use different budgets.
lighthouserc.json — working example
Place in the repository root:
{
"ci": {
"collect": {
"numberOfRuns": 3,
"startServerCommand": "npm run start",
"startServerReadyPattern": "Ready",
"startServerReadyTimeout": 120000,
"url": [
"http://localhost:3000/pl",
"http://localhost:3000/en",
"http://localhost:3000/pl/strony-www"
],
"settings": {
"preset": "desktop",
"onlyCategories": ["performance", "accessibility", "best-practices", "seo"]
}
},
"assert": {
"preset": "lighthouse:recommended",
"assertions": {
"categories:performance": ["error", { "minScore": 0.85 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
"interactive": ["warn", { "maxNumericValue": 3800 }],
"total-blocking-time": ["warn", { "maxNumericValue": 300 }]
}
},
"upload": {
"target": "temporary-public-storage"
}
}
}Practical notes:
numberOfRuns: 3— median of three runs reduces LHCI noise.- Pick business-critical URLs (home PL/EN + key service page).
- For mobile, change
presetto"perf"or add a separate job withemulatedFormFactor: "mobile"insettings(separate config file or matrix in Actions). upload.target: temporary-public-storage— quick report links in PR without your own LHCI server (for production consider LHCI Server or GitHub artifacts).
GitHub Actions — full workflow
File .github/workflows/lighthouse.yml:
name: Lighthouse CI
on:
pull_request:
branches: [main, master]
push:
branches: [main, master]
jobs:
lhci:
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Build
run: npm run build
env:
# Fill variables required by build.cjs / Next (no secrets in logs)
NODE_ENV: production
- name: Run Lighthouse CI
run: |
npm install -g @lhci/cli@0.14.x
lhci autorun
env:
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}Optional separate job on nightly deploy with Vercel Preview URL (closer to CDN):
- name: Collect against Vercel Preview
if: github.event_name == 'pull_request'
run: lhci collect --url="${{ steps.vercel.outputs.preview_url }}/pl"Preview better reflects edge cache but needs stable URL and secrets — localhost after next build is a good start without extra infrastructure.
Pipeline order — what must run before LHCI
npm cinpm run lint— fast fail before expensive buildnpm run build— same build as production (scripts/build.cjs)lhci autorun— start server, 3× URL, assert
Without step 3, LHCI measures the dev server — false green light.
Typical regressions caught in PRs
Marketing scripts
Adding gtag without strategy="lazyOnload" in next/script raises TBT and delays LCP. On the reference site:
- GA4 —
afterInteractive(do not losegenerate_leadon fast submit), - Google Ads (AW-...) —
lazyOnload(protect performance).
A PR moving all tags to afterInteractive may fix Ads but hurt Performance — LHCI should catch it; marketing must know the tradeoff (see the Google Ads conversion tracking article).
Images and fonts
- Hero:
priority, known dimensions, AVIF/WebP where possible. - Font:
next/fontordisplay: swap. - Missing
width/heighton footer logo → CLS when font loads.
JavaScript
- Dynamic import for chatbot and heavy widgets.
- Avoid large bundles in the Server Component layout.
Integration with Vercel and RUM
| Layer | What it gives |
|---|---|
| LHCI in PR | Synthetic before merge — blocks regression |
| Vercel Analytics / Speed Insights | RUM of real users after deploy |
| Search Console Core Web Vitals | Google field data with delay |
LHCI does not replace RUM — use both. LHCI is a quality gate; RUM is truth in the field (weak devices, 3G, adblock).
Bot comment on PR
With LHCI_GITHUB_APP_TOKEN (official Lighthouse CI app), reports land in PR comments with PR vs base comparison. Without the token you still have Actions logs and temporary-public-storage.
Extended assert — SEO and accessibility
Besides performance, consider:
"categories:seo": ["warn", { "minScore": 0.9 }],
"categories:accessibility": ["warn", { "minScore": 0.9 }],
"categories:best-practices": ["warn", { "minScore": 0.9 }]Start with warn, then error once stable. Otherwise every PR with a minor axe rule goes red and the team disables LHCI.
CI time cost
Estimate: 3 runs × 3 URL × ~40–60 s ≈ 6–10 minutes + Next build (2–8 min). Speed-ups:
- cache
~/.npmand.next/cachein Actions, - fewer URLs in collect (only
/plon feature branches), - full URL set only on
main.
Still cheaper than a week of conversion drop after LCP regression.
FAQ
Does Lighthouse CI replace manual PageSpeed Insights?
No — PSI is still useful ad hoc. LHCI automates the same on every PR. Keep PSI on the production URL as a post-release check.
Can I test desktop only?
Yes, but Google uses mobile for CWV in Search. Minimum: one mobile job on main, desktop on PR if CI time hurts.
Build needs environment variables — what in CI?
Duplicate non-secret variables in job env:; secrets in GitHub Secrets. build.cjs often loads the same as Vercel — without that, build fails before LHCI.
PR adds GA4 on afterInteractive — is that wrong?
Not always — it is a product decision (do not lose leads). LHCI may show Performance regression — then optimize elsewhere (images, fonts, code-split), not necessarily revert GA4.
Should eslint run in the same workflow as LHCI?
Yes — fast fail. A lint-only workflow is also fine; what matters is production build as a gate before lhci autorun.
Local run before pushing a PR
On developer machine (Node 22):
npm run build
npm run start
# in second terminal:
npx @lhci/cli autorunIf local passes but CI fails — check environment variables, Node version, and cache. If local fails but Vercel is OK — you are probably measuring dev instead of production build.
One-day rollout checklist
-
lighthouserc.jsonin repo with LCP / CLS / Performance thresholds - Workflow with Node 22,
npm ci,lint,build,lhci autorun - 2–3 URLs critical for conversion
- Secret
LHCI_GITHUB_APP_TOKEN(optional comments) - Team knows: GA4 early, Ads late — not "everything lazyOnload"
- After merge: RUM on production for verification
Example mobile job (separate matrix)
In the same workflow add a second step with mobile preset — critical for Google CWV:
- name: Run Lighthouse CI (mobile)
run: |
npm install -g @lhci/cli@0.14.x
lhci autorun --config=lighthouserc.mobile.jsonFile lighthouserc.mobile.json — copy of base config with:
"settings": {
"preset": "perf",
"formFactor": "mobile",
"screenEmulation": { "mobile": true }
}Keep LCP/CLS asserts the same — mobile is stricter, so passing mobile in CI usually means desktop is safe too.
What to do when LHCI fails after merge
- Open report from
temporary-public-storageor bot comment. - Check opportunities (images, unused JS, render-blocking).
- Compare diff with base branch — which PR introduced regression.
- Do not lower thresholds permanently without business reason — better a one-time exception with a ticket than permanent
minScore: 0.7.
Typical win: move script to lazyOnload, priority on LCP image, dynamic(() => import(...)) for chatbot.
Link to Google Ads and landing page experience
Google evaluates landing page experience partly through speed and layout stability. An LCP regression in a merged PR can raise CPC and lower Quality Score — cost visible in Ads, not only Lighthouse. The team adding AW- tags should see red LHCI before the campaign learns on a slower page.
scripts/build.cjs — why not raw next build
In production projects, build often wraps env validation, Prisma, asset copy, or steps before next build. LHCI must use the identical command as Vercel (npm run build), otherwise you measure a different bundle than users. If build fails in CI for missing DATABASE_URL, set a mock or skip DB only in the LHCI job — but document it in README for the team.
Summary
Lighthouse CI on Next.js 15 is the rule of the game: lint → production build → audit → assert. Thresholds LCP ≤ 2.5 s, CLS ≤ 0.1, Performance ≥ 0.85 protect SEO and landing page quality under Ads. Remember analytics tradeoff: GA4 afterInteractive vs Google Ads lazyOnload — the pipeline should show the cost of PRs that break that balance. Synthetic CI + RUM after deploy = full picture. Node 22, eslint, and scripts/build.cjs are the same quality chain production sees.
Want a performance pipeline for your project?
- Contact us — we configure LHCI for your monorepo / Vercel setup
- Website performance optimization — practices beyond CI
- Business websites — fast corporate sites with Core Web Vitals from day one