Skip to content
5 min read

Next.js performance: the levers that actually matter

A measurement-driven guide to Next.js performance: Core Web Vitals, shipping less JavaScript, images, fonts, caching and deferring third-party scripts.

On this page
  1. Decide what fast means, in numbers
  2. Ship less JavaScript
  3. Serve static, cache deliberately
  4. Images, which are usually the LCP element
  5. Fonts block text from painting
  6. Third-party scripts are code you do not control
  7. Look at the bundle
  8. The loop, not the checklist

You ship a feature, open the site to admire it, and it hangs. For about a second the screen sits there blank. On your laptop you would barely register it. Your user is on a mid-range phone and patchy 4G, where that second is long enough to leave, and you never see them do it.

Performance is not one switch you flip at the end. It is a handful of habits, and this is the map I use: the levers I reach for, each one linking to a deeper write-up.

Decide what fast means, in numbers

Three metrics carry the weight, because each maps to something a user feels.

LCP is loading: when the biggest above-the-fold element finishes rendering. Good is 2.5 seconds or less. CLS is visual stability, meaning how much the page jumps around while it loads, and good is 0.1 or less. INP is responsiveness across the whole visit, and good is 200 ms or less. INP replaced FID as a Core Web Vital in 2024, so if your mental model still has FID in it, update it.

Those three are the Core Web Vitals. Other metrics matter — FCP in particular — but they are diagnostics rather than the thing Google measures you on.

Measure both ways, because they answer different questions. Lighthouse in DevTools or PageSpeed Insights gives you lab data: controlled, repeatable, something you can test a single change against. Search Console's Core Web Vitals report gives you field data at the 75th percentile, which is what your users on real devices got. Lab tells you why. Field tells you whether it mattered.

Each has its own deep dive: First Contentful Paint, Largest Contentful Paint and Cumulative Layout Shift. Start with whichever one your field report says is worst, not whichever one is most interesting.

Ship less JavaScript

The 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.

App Router components are Server Components by default, rendering to HTML on the server and shipping zero JS. Keep them that way and push "use client" down to the interactive leaves rather than marking a whole page.

good-vs-bad.tsxtsx
// ❌ 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'
}

Anything below the fold or rarely opened — comment widgets, modals, charts — gets lazy-loaded so it stays off the critical path:

lazy.tsxtsx
import dynamic from 'next/dynamic';
const CommentsWidget = dynamic(() => import('./CommentsWidget')); // loaded on demand, not at first paint

Serve static, cache deliberately

The fastest response is one that is already rendered. Static generation or ISR for content pages means the HTML comes off a CDN instead of being built per request, and one line opts a route in:

app/blog/[slug]/page.tsxts
export const revalidate = 60; // ISR: serve static HTML from the edge, refresh in the background

Worth knowing that caching defaults flipped in Next.js 15. Fetches are no longer cached unless you ask, which inverts most of the advice written for 14, and Next.js 16's Cache Components change the model again. The four cache layers also interact in ways that surprise people, and knowing which one is serving a stale response is most of the debugging. I wrote that up separately: Next.js App Router caching.

The same instinct applies on the back end, which is how a 14 GiB Redis cache became 1.6 GiB with no downtime.

Images, which are usually the LCP element

next/image earns its keep: modern formats, responsive sizes, lazy-loading by default. Two rules carry most of the benefit. Put priority on the one above-the-fold image so it is not deferred, and always give explicit dimensions so the layout does not shift as it loads, which is a direct CLS win on top.

hero.tsxtsx
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 />

Fonts block text from painting

Which means they quietly hurt both FCP and CLS, and almost nobody looks at them.

next/font handles most of it: self-hosting the files so there is no extra connection to Google, subsetting to the characters you use, reserving space to avoid 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.

app/layout.tsxtsx
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 paint

Third-party scripts are code you do not control

Analytics, pixels, chat widgets, tag managers. By default they can block your first paint, and the vendor has no reason to care.

next/script with the right strategy fixes it. afterInteractive for things that should run soon but not before the page is usable, lazyOnload for low-priority widgets that can wait for idle. Neither blocks the initial render.

app/layout.tsxtsx
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" />

Look at the bundle

You cannot trim what you cannot see. @next/bundle-analyzer gives you a treemap of what is in each bundle, and it is the fastest way to catch a large dependency that slipped into a client component. A date library, an icon set imported whole, a charting package dragged in by one small widget.

Wrap your config, build with ANALYZE=true, look for the big rectangles. Nine times out of ten the fix is a lighter dependency or moving the code to the server. I went through the whole process in what's in your Next.js bundle.

The loop, not the checklist

None of these levers is heroic alone. Server Components trim JS. next/image and next/font cut loading cost. Static rendering moves work to the edge. Deferred scripts stay out of the way. The analyzer keeps you honest.

What makes them work is applying them as a loop rather than running the list once. Measure to find the worst metric, fix one lever, measure again to confirm it helped and to see what is now the bottleneck, repeat.

That is duller than any single trick and it is the only thing I have seen keep a site fast as it grows, instead of regressing quietly release by release until someone files a ticket about it.

Frequently asked

What are the Core Web Vitals in 2026?

The three Core Web Vitals are Largest Contentful Paint (LCP), which measures loading (good is ≤2.5s); Cumulative Layout Shift (CLS), which measures visual stability (good is ≤0.1); and Interaction to Next Paint (INP), which measures interactivity (good is ≤200ms). INP replaced First Input Delay (FID) as a Core Web Vital in March 2024 because it captures responsiveness across the whole visit rather than just the first interaction.

How do I make a Next.js site faster?

Work through the main levers in order: measure your Core Web Vitals first, ship less client JavaScript by keeping Server Components and pushing "use client" to the leaves, render pages statically (SSG/ISR) so HTML comes from a CDN, optimize images with next/image, self-host and subset fonts with next/font, and defer third-party scripts with next/script. Change one thing at a time and re-measure so you know what actually helped.

Do Server Components improve performance?

Yes. Server Components render to HTML on the server and ship zero JavaScript to the browser, so they cut the amount of code the client has to download, parse, and execute. Less client JavaScript means a faster first paint and better INP. The pattern in the Next.js App Router is to keep components as Server Components by default and mark only the interactive leaves with "use client".

Does next/image improve Core Web Vitals?

It helps two of them directly. next/image serves modern formats and responsive sizes, which shrinks the download and improves LCP when the image is your largest element — especially if you add the priority prop to the above-the-fold image. Because it requires explicit width and height (or fill), it also reserves space and prevents the layout shifts that hurt CLS.

How do I reduce JavaScript bundle size in Next.js?

Keep components as Server Components wherever possible so they ship no JavaScript, lazy-load below-the-fold or rarely used components with next/dynamic, and import only what you need from large libraries rather than the whole package. Run @next/bundle-analyzer to see exactly what is in each bundle and catch heavy dependencies that have leaked into client components.

How do I measure Next.js performance?

Use two kinds of data. For lab data, run Lighthouse in Chrome DevTools or PageSpeed Insights on the mobile profile to get a repeatable score you can test changes against. For field data, watch Google Search Console’s Core Web Vitals report, which shows what real users experience at the 75th percentile. Measure before and after each change so improvements are verified, not assumed.

Found this useful?Share on XShare on LinkedIn

Related reading