Optimizing Next.js for Core Web Vitals
Practical performance wins from real client projects — images, fonts, code splitting, and Lighthouse audits that actually matter.
Nguyễn Đình Giang
· · 5 min read
Frontend developer in Ha Noi. I write about the Next.js, WordPress, and performance work I actually ship.
Performance isn't a one-time task. On every client handoff, I run Lighthouse, check Core Web Vitals, and fix the issues that affect real users — not just the audit score.
The checklist I use
Images
- Use
next/imagewith explicitwidth/height - Serve WebP/AVIF where possible
- Lazy-load below-the-fold media
- Don't ship 4000px hero images for a 1200px container
JavaScript
- Dynamic import heavy components (carousels, charts, animation libs)
- Avoid shipping GSAP ScrollTrigger on pages that don't use it
- Keep client components small — push data fetching to the server
const ProjectGallery = dynamic(
() => import("./project-gallery").then((m) => m.ProjectGallery),
{ ssr: false }
)Fonts
- Subset fonts to needed characters
- Use
display: swapto avoid invisible text during load - Limit font families — two is usually enough
Caching
- Set sensible
revalidatefor CMS-backed pages - Cache static assets with long TTL at the CDN layer
- Don't re-fetch unchanged WordPress data on every request
Metrics I watch
| Metric | What it tells you |
|---|---|
| LCP | Is the main content visible fast enough? |
| INP | Do interactions feel responsive? |
| CLS | Does the layout jump while loading? |
Case study: this portfolio’s homepage (lab)
I ran the same checklist on rivernguyen.id.vn during a full-site audit (lab Lighthouse only — no CrUX / PageSpeed API credentials configured yet).
| Surface | Device | Performance | LCP | Note |
|---|---|---|---|---|
/ | Mobile | ~44 | ~5.1s | Poor — critical path crowded |
/ | Desktop | ~90 | ~0.7s | Fine |
/projects | Mobile | ~90 | ~2.6s | Needs improvement on LCP |
| Blog post | Mobile | ~74 | ~4.6s | Content pages still heavy |
CLS stayed excellent (≤0.002). The homepage mobile collapse was not “Next.js is slow” — it was unused analytics modules (surveys / heatmaps / toolbar shipped with the SDK) plus Motion as a page-global chunk, plus too many simultaneous font/image preload hints.
What I am changing on this site (same playbook as client work):
- Defer analytics until idle; disable unused product modules
- Dynamic-import motion / chat / duck follower off the first paint
- Cap decorative font preloads; keep one body + one mono hot
- Serve
/avatarnear display size (~240²), not a 400² file for a ~120px box
I will replace these lab numbers with field data once Search Console / CrUX is wired. Until then, treat them as Mobile lab, the same label I put on case study metrics.
Related rebuild narrative: Rebuilding my developer portfolio.
Real project example
On a multilingual tour-booking platform, I improved PageSpeed by combining image optimization, code splitting, and deferring non-critical scripts — the same build as the tour booking platform sibling. The site targeted international tourists on mobile networks — every 100ms matters. For the CMS side of those builds, see WordPress as a headless CMS.
What I actually measured
On the multilingual tour platform the number I published is 1.5s LCP, Lighthouse mobile lab, not CrUX field data. The page is a filter-heavy booking UI for guests on phones, English and Chinese, with a WordPress REST backend. I am not claiming a before/after delta I did not record. The lab result and the conditions are the result.
What moved that lab run:
- Hero and card images served through
next/imageat the rendered size, AVIF/WebP, not a 2000px export in a 400px slot - GSAP and the gallery carousel loaded with
next/dynamicon the routes that use them, not on the article template - Fonts limited to the faces that paint the first screen. A display face used once below the fold does not get
preload - WordPress responses cached with a sane
revalidateso a filter change does not refetch the whole catalog
Lighthouse mobile is a simulated slow device. It is the right check before handoff and the wrong number to quote as “what users in Hanoi felt last week” unless CrUX says so. I label the case study Mobile lab for that reason.
The checks that fail quietly
LCP is usually the hero image or the first text block, blocked by too many preload hints. Eleven high-priority preloads (fonts plus the LCP image plus CSS) fight each other. Preload the LCP image and the one body font. Let the rest load with font-display: swap.
INP follows main-thread JavaScript. A full analytics SDK parsed on first load, including surveys and heatmaps you never open, shows up as long tasks. I defer that work until requestIdleCallback and turn the unused modules off. Animation libraries stay in the route that animates, not in the root layout of a text page.
CLS on these projects stays low when images have width and height and the header does not swap fonts after paint. I still watch it because a late cookie banner or a mascot sprite without a reserved box will blow a good score.
How I audit a handoff
- Lighthouse mobile, slow 4G, logged-out, on the template the guest actually lands on (listing, not the empty dashboard).
- Note LCP element, transfer size, and whether it was preloaded.
- Coverage: which JS bytes never execute on that template.
- Fix the largest unused chunk and the oversized image. Re-run once. Stop when the lab metric is in the range I am willing to put on the case study, with the condition written next to it.
The same pass is what I use on this portfolio when a new client chunk lands in the root layout. web.dev’s thresholds are the reference I grade against: Core Web Vitals.
A preload budget
I keep a short list of what is allowed to be high priority on the first HTML response:
- The document and the CSS required to paint text
- One sans font and one mono font, if both are on screen
- The LCP image, with explicit width and height
Everything else waits. Pixel display fonts, a second illustration, analytics, and a click sound are not on that list. On this site the pixel face is a single file, not the whole five-face family, because importing the family barrel makes next/font preload every face. The avatar file is 240×240, which covers a retina thumbnail without shipping a 400×400 JPEG for a 40px byline.
If a new dependency adds a preload I did not ask for, I delete it before I look at the Lighthouse score again. The score moves after the network queue is short. It does not move because I tweaked a meta tag.
Final thought
Chasing a perfect Lighthouse score is less important than fixing what users feel: slow heroes, janky scroll, and layout shifts on load.
Start with images and JavaScript bundle size. That alone fixes most problems.