← Back to Blog
Performance

Next.js Performance Optimization: A Practical Guide

PerformanceNext.jsReact
Sandeep Kumar5 min read · July 8, 2026
Next.js Performance Optimization: A Practical Guide

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.

good-vs-bad.tsx
// ❌ 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:

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

Serve 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:

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

Beyond 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).

hero.tsx
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.

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

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

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

Frequently asked questions

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.