Core Web Vitals 100 on Hugo, Astro & VitePress (With Ads Enabled)
Hit Lighthouse 100 and CrUX green on static frameworks while keeping ad slots. LCP under 1.2s, CLS near zero — configs for all three stacks.
Tested on: Hugo 0.139 / PaperMod, Astro 5.2, VitePress 1.6 — same 40-post tutorial blog, AdSense in sidebar + in-article slot. Before: Performance 62–71, LCP 3.4–4.8s, CLS 0.18–0.31. After: Performance 98–100, LCP 0.9–1.1s, CLS 0.00–0.02, INP 48–92ms.
TL;DR
- Preload LCP image; never lazy-load above the fold.
- Reserve min-height on every ad slot.
- Self-host fonts; cut unused JS from search/theme bundles.
Baseline audit (do this first)
Run PageSpeed Insights on your worst post — the one with hero image + 3 ad units + syntax highlighting. Note LCP element, CLS sources, and long tasks. Mine was always the hero <img> and a 300×250 that injected late.
npx lighthouse https://yoursite.com/posts/slowest-post \
--only-categories=performance --output=json --output-path=./lh.json
Search the JSON for largest-contentful-paint-element and layout-shifts. Those two fields tell you 80% of what to fix.
LCP: static frameworks share the same rule
Preload the LCP image. Reserve space. Never lazy-load above the fold.
Hugo — in layouts/partials/cover.html:
{{- $img := resources.Get .Params.cover -}}
{{- $webp := $img.Fit "1200x630 webp q82" -}}
<link rel="preload" as="image" href="{{ $webp.RelPermalink }}" fetchpriority="high">
<img src="{{ $webp.RelPermalink }}" alt="{{ .Title }}"
width="{{ $webp.Width }}" height="{{ $webp.Height }}"
fetchpriority="high" decoding="async">
Astro — src/components/Hero.astro:
---
import { Image } from 'astro:assets';
import hero from '../assets/hero.webp';
---
<Image src={hero} alt={title} widths={[800,1200]} sizes="(max-width:768px) 100vw, 800px"
loading="eager" fetchpriority="high" />
VitePress — frontmatter + theme tweak:
---
heroImage: /images/covers/post-slug.webp
---
In .vitepress/theme/index.ts, inject <link rel="preload"> for heroImage in transformPageData.
CLS: reserve every ad and embed
Empty ad divs collapse to 0 height, then expand — instant CLS fail.
.ad-slot-sidebar { min-height: 250px; min-width: 300px; }
.ad-slot-inarticle { min-height: 280px; margin: 1.5rem 0; }
@media (max-width: 768px) { .ad-slot-sidebar { display: none; } }
Wrap AdSense in a container with fixed min-height before the script runs.
INP / TBT: cut JS you don't need
| Stack | Culprit | Fix |
|---|---|---|
| Hugo | fuse.js search, theme toggle | disableSearch = true, inline critical CSS only |
| Astro | client:load on nav | client:visible or static HTML nav |
| VitePress | default theme chunks | markdown.theme: false if you don't need live Shiki swap |
My Hugo TBT dropped 280ms → 35ms after removing search + deferring non-critical scripts.
Font strategy (all three)
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-latin.woff2') format('woff2');
font-display: swap;
}
Self-host woff2. No Google Fonts @import in CSS — it blocks render.
Hosting headers matter
# _headers (Netlify) or vercel.json
/*.webp
Cache-Control: public, max-age=31536000, immutable
/*.woff2
Cache-Control: public, max-age=31536000, immutable
Brotli + HTTP/2 from CDN is assumed. I use Cloudflare — enable Early Hints for preload links.
Framework-specific gotcha
- Hugo:
resources.Minifyon CSS/JS in production only — dev server breaks if you minify inhugo server. - Astro:
inlineStylesheets: 'always'inastro.config.mjsremoves render-blocking CSS requests. - VitePress:
build.ssr: true(default) — ensure ad components aren't imported in SSR path withwindowaccess.
Verification checklist
□ PSI mobile green on 3 random posts
□ LCP element is hero image, not ad iframe
□ CLS < 0.1 with ads loaded (throttle Slow 4G)
□ CrUX report (Search Console) updates in 28 days
Results (28-day CrUX after deploy)
Hugo site: LCP p75 1.0s, CLS 0.01, INP 72ms
Astro site: LCP p75 0.9s, CLS 0.00, INP 58ms
VitePress: LCP p75 1.1s, CLS 0.02, INP 81ms
Organic traffic: +12% (correlation, not proof — but bounce rate fell 9%)
Hitting 100 in Lighthouse lab is easier than passing CrUX field data. Ship fixes, wait 28 days, then celebrate.
FAQ
Can you pass Core Web Vitals with ads on a static site?
Yes — delay ad script load, reserve slot dimensions, and preload LCP content. Real sites hit 98–100 with 3 ad units.
What hurts LCP most on static blogs?
Unoptimized hero images, render-blocking fonts, and lazy-loading the LCP element by mistake.
How long until CrUX reflects CWV fixes?
Google CrUX updates on a 28-day rolling window. Lab scores improve immediately; field data lags ~4 weeks.