NNicheStackTutorials
← All tutorials
SEO & Web Vitals

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.

·14 min read·SEO & Web Vitals
Core Web Vitals 100 on Hugo, Astro & VitePress (With Ads Enabled) cover

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

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

StackCulpritFix
Hugofuse.js search, theme toggledisableSearch = true, inline critical CSS only
Astroclient:load on navclient:visible or static HTML nav
VitePressdefault theme chunksmarkdown.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.Minify on CSS/JS in production only — dev server breaks if you minify in hugo server.
  • Astro: inlineStylesheets: 'always' in astro.config.mjs removes render-blocking CSS requests.
  • VitePress: build.ssr: true (default) — ensure ad components aren't imported in SSR path with window access.

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.

Tags: Core Web VitalsLighthouseHugoAstroVitePress