Building a Production Landing Page That Scores 100 on Core Web Vitals

Photo by Unsplash

Photo by Unsplash
Every developer has launched a landing page they were proud of, only to run Lighthouse and watch the score collapse under a barrage of red. I've been there. After shipping several production Next.js sites — including this portfolio — I've developed a repeatable process for hitting green across the board: LCP under 2.5 seconds, CLS below 0.1, and INP under 200ms. This post is that process, unfiltered.
Google's Core Web Vitals (CWV) are field metrics, not lab metrics. That distinction matters enormously. Lighthouse runs in a controlled lab environment on a simulated mid-tier device. CWV are measured on real users visiting your site, reported back via the Chrome User Experience Report (CrUX). A perfect Lighthouse score doesn't guarantee good field data — and vice versa. Knowing this changes how you approach optimization: you're not just tuning for a testing tool, you're tuning for real hardware and real network conditions.
LCP measures when the largest visible content element finishes rendering. On a typical landing page, that's the hero image or the above-the-fold headline. The most common culprit for poor LCP is treating the hero image like any other image — lazy-loading it. Next.js's Image component lazy-loads by default, which is correct for below-the-fold images but catastrophic for the hero. Always add priority to your hero image. This emits fetchpriority="high" and removes the lazy-loading intersection observer, letting the browser start fetching the image immediately during HTML parsing.
CLS is the one metric that can explode without any code change — just add a banner, a cookie consent popup, or an ad slot that loads after the initial paint. The rule is simple: never inject content above existing content. Reserve space for every dynamic element using explicit width/height or aspect-ratio CSS before content loads. Next.js's Image component does this automatically when you provide width and height props. For fonts, use next/font/google with display swap and preload true.
Browser Request
│
▼
┌─────────────────────────────────────────────────────┐
│ NAVIGATION TIMING │
│ │
│ TTFB (Time to First Byte) │
│ ├── DNS Lookup │
│ ├── TCP Connect │
│ └── Server Response │
│ │
│ FCP (First Contentful Paint) ◄── HTML/CSS parse │
│ LCP (Largest Contentful Paint)◄── Hero image load │
│ CLS (Cumulative Layout Shift) ◄── Layout complete │
│ INP (Interaction to Next Paint)◄── JS hydration │
└─────────────────────────────────────────────────────┘
Target: LCP < 2.5s │ INP < 200ms │ CLS < 0.1Use a preload link for your LCP image if it's a background-image set via CSS rather than an img tag — Next.js Image's priority prop only works for img elements. Add it manually in the document head via next/head or the metadata API.
A well-configured next.config.ts eliminates entire categories of performance problems before a single line of component code is written. The key levers are image optimization, CSS handling, and bundle analysis. Enabling AVIF as the first image format preference can cut image sizes by 40–50% compared to WebP, with the browser falling back automatically. Setting a long minimumCacheTTL ensures optimized images are served from cache on repeat visits.
Fonts are one of the most overlooked sources of both CLS and LCP regressions. Using next/font/google instead of a manual @import or link tag gives you automatic subsetting, self-hosting on your own domain, and preloading baked into the component. For third-party scripts — analytics, chat widgets, A/B testing tools — always use Next.js's Script component with strategy lazyOnload unless the script is genuinely render-critical.
// next.config.ts — production-grade config
import type { NextConfig } from "next"
const nextConfig: NextConfig = {
images: {
formats: ["image/avif", "image/webp"],
deviceSizes: [640, 750, 828, 1080, 1200],
minimumCacheTTL: 60 * 60 * 24 * 30, // 30 days
},
experimental: {
optimizeCss: true, // inline critical CSS
},
}
export default nextConfig
// Hero component — LCP-critical image
import Image from "next/image"
export function HeroSection() {
return (
<section>
<Image
src="/hero.webp"
alt="Product hero"
width={1200}
height={630}
priority // fetchpriority="high" — never lazy-load LCP
placeholder="blur"
blurDataURL="data:image/webp;base64,..."
/>
</section>
)
}
// app/layout.tsx — preload critical font
import { Inter } from "next/font/google"
const inter = Inter({
subsets: ["latin"],
display: "swap", // prevent FOIT
preload: true,
})
// Prevent CLS: always reserve space for dynamic content
// .ad-slot { min-height: 90px; }
// Never inject content above existing DOM nodesINP replaced FID as a Core Web Vital in March 2024, and it's significantly harder to optimize. Where FID only measured the delay before the first interaction, INP measures the worst interaction across the entire page session. The primary cause of poor INP is long tasks on the main thread — JavaScript that runs for more than 50ms without yielding. Next.js's server components help by moving rendering off the client entirely.
One third-party script loaded with strategy=beforeInteractive can single-handedly tank your INP score by blocking the main thread during hydration. Audit every script tag in your codebase. Use strategy=afterInteractive or lazyOnload as the default, and test the difference in Lighthouse before accepting any vendor requirement.
The only way to maintain a perfect Lighthouse score over time is to make it impossible to merge a PR that breaks it. I use Lighthouse CI in GitHub Actions, configured to fail the build if any CWV metric drops below threshold. The workflow runs on every pull request against a preview deployment URL. Combined with bundle-size diff comments, this means every reviewer can see the performance impact of a change before it merges.
Before any landing page goes live, I run through this checklist: hero image has priority prop and is served as WebP/AVIF; all fonts use next/font; no plain script tags in layout; explicit dimensions on all images and embeds; no content injected above the fold post-paint; Lighthouse CI passes in the PR; bundle analyzer shows no unexpected large dependencies.