How I Optimized a Next.js App's Core Web Vitals from 60 to 95
2026-02-14 · 6 min read
Lighthouse scores are vanity metrics until you trace them back to specific, fixable causes. After optimizing CodeMyFYP Academy from an initial 60-something Performance score to consistently above 95, here's the playbook I use.
LCP: the biggest wins are usually images
Largest Contentful Paint measures how long the largest above-the-fold element takes to render — usually a hero image or a banner. On CodeMyFYP Academy, the LCP element was the hero course banner, served as a 1.2MB PNG.
Fix 1: next/image with priority
<Image
src="/banner.png"
alt="Course banner"
width={1200}
height={600}
priority // ← tells Next.js to preload this image
quality={80}
/>
priority adds a <link rel="preload"> in the <head>, starting the download before the browser parses the component. Combined with WebP conversion (handled automatically by next/image), this cut LCP from 3.2s to 1.1s.
Fix 2: Size images to the display size
Serving a 2400×1200 image for a container that's 800×400 wide is wasteful. next/image with proper sizes attribute serves the right size per viewport:
<Image
src="/banner.png"
fill
sizes="(max-width: 768px) 100vw, 50vw"
/>
CLS: layout shifts from fonts and dynamic content
Cumulative Layout Shift measures unexpected visual movement. Two common culprits:
Font swap jank: When a fallback system font is replaced by a web font, text reflows. Fix with font-display: optional (skip swap entirely for non-critical fonts) or font-display: swap with size-adjusted fallback metrics:
@font-face {
font-family: 'Inter';
src: url('/fonts/inter.woff2') format('woff2');
font-display: optional;
size-adjust: 100%;
}
Unsized images: Any image without explicit width and height causes layout shift as it loads. Next.js fill mode with a sized parent avoids this.
FID/INP: JavaScript is the culprit
First Input Delay (and its successor Interaction to Next Paint) measures responsiveness. The cause is almost always too much JavaScript on the main thread.
Fix 1: Code splitting
Next.js automatically splits by route. The additional optimization is splitting by visibility — don't load components that are below the fold on initial paint:
import dynamic from "next/dynamic";
const CourseReviews = dynamic(() => import("./CourseReviews"), {
loading: () => <ReviewsSkeleton />,
});
CourseReviews only loads when rendered, not on initial page load.
Fix 2: Defer non-critical scripts
Third-party analytics, chat widgets, and social share buttons are often blocking. Load them after the page is interactive:
<Script src="/analytics.js" strategy="lazyOnload" />
ISR for data-heavy pages
For course listing pages that change infrequently, Incremental Static Regeneration (ISR) serves a pre-built HTML page rather than server-rendering on every request:
export async function generateStaticParams() {
const universities = await db.universities.findMany();
return universities.map(u => ({ slug: u.slug }));
}
export const revalidate = 600; // Rebuild every 10 minutes
Switching the university course page from SSR to ISR cut TTFB from 800ms to 45ms.
The measurement cycle
The pattern that worked: Lighthouse → identify the worst metric → find the root cause in DevTools Network and Performance tabs → fix → measure again. Don't optimize multiple things simultaneously or you won't know what worked.
The biggest mistake: optimizing for Lighthouse in isolation. Real users on slow networks and mid-range devices experience performance differently. I use WebPageTest with a throttled mobile profile for final validation.