Here’s a moment every developer knows. You ship a feature, open the site to admire it, and… it just hangs. For a full second the screen sits there, blank. On your fast laptop you’d barely notice — but your user is on a mid-range phone and spotty 4G, and to them that second feels like forever.
So they leave. And you never even see it happen.
I’ve been there more times than I’d like to admit. What I learned the hard way is that performance isn’t one magic switch you flip at the end — it’s a handful of small habits, like wiping down the kitchen as you cook instead of facing a mountain of dishes later. This post is my map: the levers I actually reach for to keep a Next.js app fast, each one practical, and each linking out to a deeper dive.
Start with Core Web Vitals
Before you optimize anything, decide what "fast" means in numbers. Google’s Core Web Vitals are the three metrics that matter most, because they map to what a user actually feels — how fast the page loads, whether it stays still, and how quickly it responds when tapped.
- LCP (Largest Contentful Paint) — loading. When the biggest above-the-fold element finishes rendering. Good is 2.5 seconds or less.
- CLS (Cumulative Layout Shift) — visual stability. How much the page jumps around while it loads. Good is 0.1 or less.
- INP (Interaction to Next Paint) — interactivity. How quickly the page responds to taps and clicks across the whole visit. Good is 200 ms or less. INP replaced FID as a Core Web Vital in 2024.
Measure both ways. Use Lighthouse (in Chrome DevTools) or PageSpeed Insights for lab data — a controlled, repeatable score you can test a change against. Then watch Search Console’s Core Web Vitals report for field data — what your real users on real devices actually experience at the 75th percentile. Lab tells you why; field tells you whether it mattered.
Each of these has its own deep dive: First Contentful Paint (FCP), Largest Contentful Paint (LCP), and Cumulative Layout Shift (CLS). Start here, then follow the one your report says is worst.
Ship less JavaScript
The single biggest lever for a React app is sending the browser less JavaScript to download, parse, and execute. Every kilobyte competes with your first paint and drags on INP. In the Next.js App Router, components are Server Components by default — they render to HTML on the server and ship zero JS. Keep it that way, and push "use client" down to the interactive leaves instead of marking a whole page.
// ❌ the entire page ships as client JS just for one interaction
'use client';
export default function Page() { /* ...lots of static markup... */ }
// ✅ page stays a Server Component; only the button ships JS
export default function Page() {
return <><StaticContent /><LikeButton /></>; // LikeButton is 'use client'
}For anything below the fold or rarely opened — comment widgets, modals, charts — lazy-load it with next/dynamic so it stays out of the initial bundle and the critical path:
import dynamic from 'next/dynamic';
const CommentsWidget = dynamic(() => import('./CommentsWidget')); // loaded on demand, not at first paintServe static, cache aggressively
The fastest response is one that’s already rendered. Use static generation (SSG) or incremental static regeneration (ISR) for content pages so the HTML comes from a CDN instead of being built per request. A single line opts a route into ISR — static speed, with background refresh:
export const revalidate = 60; // ISR: serve static HTML from the edge, refresh in the backgroundBeyond that, the App Router has several cache layers — the Request Memoization, Data Cache, Full Route Cache, and client-side Router Cache — and they interact in ways that surprise people. Knowing which one is serving a stale response is half the battle.
This is worth understanding in full, so I wrote it up separately: Next.js App Router caching, finally explained.
Optimize images
Images are usually the heaviest thing on a page and often the LCP element itself. The next/image component earns its keep: it serves modern formats (AVIF/WebP), generates responsive sizes, and lazy-loads by default. Two rules matter most — put priority on the one above-the-fold image so it isn’t deferred, and always give explicit dimensions so the layout doesn’t shift as it loads (that’s a direct CLS win).
import Image from 'next/image';
// LCP image: priority (skip lazy-loading) + explicit dimensions (no layout shift)
<Image src="/hero.jpg" alt="…" width={1200} height={630} priority />Tame fonts
Web fonts block text from painting until the file arrives, so they quietly hurt both FCP and CLS. next/font fixes most of it for you: it self-hosts the files (no extra connection to Google), subsets them to the characters you use, and reserves space to avoid layout shift. Add display: "swap" so text shows immediately in a fallback, and preload only the font on the critical path — a code or label font can wait.
import { Plus_Jakarta_Sans, Geist_Mono } from 'next/font/google';
const sans = Plus_Jakarta_Sans({ subsets: ['latin'], display: 'swap' }); // critical, preloaded
const mono = Geist_Mono({ subsets: ['latin'], display: 'swap', preload: false }); // not first paintDefer third-party scripts
Analytics, pixels, chat widgets, tag managers — third-party scripts are code you don’t control, and by default they can block your first paint. Load them with next/script and the right strategy: afterInteractive for things that should run soon but not before the page is usable, and lazyOnload for low-priority widgets that can wait for idle time. Neither blocks the initial render.
import Script from 'next/script';
// analytics: runs after the page is interactive, never blocks first paint
<Script src="https://www.googletagmanager.com/gtag/js?id=..." strategy="afterInteractive" />
// low-priority chat widget: waits for browser idle time
<Script src="https://widget.example.com/chat.js" strategy="lazyOnload" />Measure the bundle
You can’t trim what you can’t see. @next/bundle-analyzer produces a treemap of what’s actually in each bundle, and it’s the fastest way to catch a large dependency that snuck into a client component — a date library, an icon set imported in full, a charting package pulled in by one small widget. Wrap your config, run a build with ANALYZE=true, and look for the big rectangles. Nine times out of ten the fix is a lighter dependency or moving the code to the server.
The takeaway
None of these levers is heroic on its own. Server Components trim JS; next/image and next/font cut the loading cost; static rendering and caching move work to the edge; deferred scripts stay out of the way; the bundle analyzer keeps you honest. What makes them work is the order you apply them in — as a loop, not a checklist you run once.
Measure to find the worst metric. Fix one lever. Measure again to confirm it helped and to see what’s now the bottleneck. Repeat. That discipline beats any single trick, and it’s what keeps a site fast as it grows instead of slowly regressing release by release.
Performance isn’t a milestone you hit once. It’s a loop — measure, fix one thing, measure again — and the sites that stay fast are the ones that never stop running it.