← Back to all posts

Next.js performance optimization that actually matters.

I audited a Next.js app last month that shipped 2.4MB of JavaScript on first load. After a week of targeted optimizations, it was down to 340KB. Here's every technique I used.

Dashboard showing web performance metrics

Performance work is unglamorous. Nobody tweets about the afternoon they spent staring at a webpack bundle visualizer, swapping a 68KB date library for a 3KB one. But that's where the real gains live — not in clever tricks, but in methodical elimination of waste. The Next.js app I audited wasn't slow because of one catastrophic mistake. It was slow because of fifty small decisions that nobody questioned.

What follows is the playbook I used to cut that app's bundle by 86%. Every technique here applies to any Next.js 13+ project using the App Router. Most of them are free. All of them compound.

Start with measurement, not hunches

The first instinct when a site feels slow is to start optimizing. Resist it. You don't know what's slow yet. You have a hunch, and hunches are wrong more often than developers like to admit.

Lighthouse is a starting point, not a verdict. It runs on your machine, with your CPU, your network, and your extensions. It gives you a score that means almost nothing in isolation. What matters is real user data — the metrics your actual visitors experience on their actual devices.

The three numbers that matter most:

  • LCP (Largest Contentful Paint): How long until the main content is visible. Target: under 2.5 seconds at p75.
  • INP (Interaction to Next Paint): How responsive the page feels when users interact with it. Target: under 200ms at p75.
  • CLS (Cumulative Layout Shift): How much the page jumps around while loading. Target: under 0.1 at p75.

The key phrase is "at p75" — the 75th percentile of real user experiences. Not the median, not the best case, not your MacBook Pro on gigabit fiber. The number that captures what most of your users actually see. Set up Web Vitals reporting in production before you change a single line of code. Without a baseline, you're optimizing blind.

Server Components are the biggest win

If you take one thing from this article, let it be this: default to Server Components. Every component in the App Router is a Server Component by default. The moment you add 'use client' at the top of a file, you're making a decision to ship that component's JavaScript to the browser. That's not free.

In the app I audited, 73 out of 89 components had 'use client' directives. Most of them didn't need it. They were marked as client components because they imported something from a client component, or because the developer wasn't sure and 'use client' made the error go away.

The mental model is simple: Server Components are for content. Client Components are for interactivity. If a component renders text, images, or data from a database and doesn't need click handlers, hover states, or browser APIs — it's a Server Component. Full stop.

Move data fetching to the server. Render your layouts on the server. Keep your navigation on the server. The client boundary should be pushed as far down the component tree as possible — wrapping only the specific button, form, or interactive widget that genuinely needs browser JavaScript.

Image optimization is free performance

The single easiest performance win in any Next.js project is using the <Image> component from next/image. It handles automatic format conversion (WebP and AVIF), responsive sizing, lazy loading, and blur placeholders out of the box. And yet, in every audit I do, I find raw <img> tags serving uncompressed PNGs.

Images are often the heaviest assets on a page. A single unoptimized hero image can weigh more than your entire JavaScript bundle. The math is brutal: a 2MB JPEG served as a 200KB WebP is a 90% reduction with zero visual difference. Multiply that across every image on your site.

Three rules for images in Next.js:

  • Always use <Image> from next/image. No exceptions. The automatic optimization alone is worth it.
  • Set explicit width and height on every image. This prevents CLS — the browser reserves space for the image before it loads, so the page doesn't jump.
  • Serve modern formats. Configure your image loader to serve WebP with AVIF fallback. Let the browser negotiate the best format.

Most of the performance gains I've delivered in my career came from images, not from clever JavaScript optimizations. It's not glamorous work, but the numbers don't lie.

Bundle analysis and tree shaking

Run @next/bundle-analyzer at least quarterly. Open the treemap. Look at what's actually in your bundle. You will be surprised. Every time.

The usual offenders:

  • moment.js — 68KB gzipped, includes every locale. Replace with date-fns (tree-shakeable, import only what you use) or the native Intl.DateTimeFormat API.
  • lodash — If you're importing the full library, you're shipping 72KB for the three functions you actually use. Import individual functions: import debounce from 'lodash/debounce'.
  • Icon libraries — A full icon library can be 200KB+. Import specific icons: import { ArrowRight } from 'lucide-react', not import * as Icons from 'lucide-react'.
  • Syntax highlighters, rich text editors, chart libraries — These are heavy. Use next/dynamic to load them only when they're needed, especially if they're below the fold.

Dynamic imports are your best friend for heavy components that aren't needed on initial render. A chart that's three scrolls down the page doesn't need to be in your initial bundle. Load it when the user scrolls to it, or at minimum, load it after the page is interactive.

Caching layers that compound

Next.js gives you multiple caching strategies, and the right one depends on how often your content changes. The trick is layering them so each one builds on the last.

  • Static Generation (SSG) for content that changes weekly or less. Your about page, your blog posts, your documentation. These are built at deploy time and served from the CDN edge. Zero server cost per request.
  • Incremental Static Regeneration (ISR) for content that changes hourly or daily. Product pages, dashboards with semi-fresh data. Set a revalidate interval and Next.js rebuilds the page in the background while serving the cached version.
  • Server-side rendering (SSR) only for content that must be fresh on every request. Personalized pages, real-time data. Use cache: 'no-store' on your fetch calls.

The stale-while-revalidate pattern is particularly powerful: serve the cached version immediately (fast), then regenerate in the background (fresh). The user gets speed, and the content stays current within your tolerance window.

Don't forget CDN caching headers. Set Cache-Control headers on your static assets with long max-age values. Use s-maxage for CDN caching and stale-while-revalidate for edge caching. These headers are free and they eliminate entire round trips to your origin server.

Performance isn't a feature you add at the end. It's a tax you pay for every dependency you didn't question, every client component you didn't need, and every image you didn't compress.

Closing thought

The fastest sites I've worked on don't feel fast because of a trick. They feel fast because someone asked "does this need to be here?" about every kilobyte. Every dependency, every client directive, every uncompressed image, every font file — each one is a small tax on your users' time and bandwidth.

Performance work isn't about finding the one magic optimization. It's about building a culture of questioning. Does this library earn its weight? Does this component need to run on the client? Can this image be smaller? The answers are almost always yes, yes, and yes — and each one shaves another hundred milliseconds off the experience.

Start with less. Add only what earns its weight. Measure everything. The compound effect of fifty small improvements will always beat one clever hack.


↳ Keep reading

More from the blog.

— Stay in the loop

Like what you read?

Get an email when I publish something new. Roughly monthly, never spam.