The problem with most Next.js performance advice
Most of the "optimize your Next.js app" content I've read focuses on things that don't actually move the needle: adding next/image to images that are already off-screen, tweaking Lighthouse scores in ways users never notice, or server-rendering pages that don't need server rendering.
After profiling three production Next.js apps — one with 100k MAU, two in the 5-20k range — I found the same patterns showing up every time. The wins that actually made users notice came from a small set of decisions. Here they are.
Understand the rendering pipeline first
Before you can optimize anything, you need a clear model of what Next.js is actually doing. The RSC (React Server Components) pipeline introduced in Next.js 13+ is genuinely different from the old pages router, and the mental model matters.
Two things that aren't obvious until you see them:
- Server Components run only on the server and never ship to the client. Their code doesn't appear in your JS bundle. A 200-line RSC that renders a blog post adds zero bytes to the browser download. This is the primary reason to default to RSC.
- The Full Route Cache is your biggest lever for static content. If a route doesn't depend on request-time data (cookies, headers, dynamic segments that vary), Next.js can cache the entire rendered HTML output. That request never hits your server again — it serves from CDN in ~10ms.
The Server vs Client Component decision
The single highest-impact habit in Next.js is getting this decision right for every component. The rule is simple but easy to get backwards:
The default answer should always be Server Component. You only reach for "use client" when you specifically need interactivity, browser APIs (window, localStorage), or React hooks (useState, useEffect).
The mistake I see most often: wrapping large sections of a page in "use client" because one small part of it needs a click handler. Instead, push the client boundary down as far as it can go. A page can be a Server Component while containing a "use client" button buried three levels deep — and that's exactly right.
// ❌ Wrong: entire section is client-side for one interactive element
"use client";
export function ProjectSection({ projects }) {
const [selected, setSelected] = useState(null);
return (
<section>
{projects.map(p => <ProjectCard key={p.id} project={p} />)}
<ProjectModal project={selected} />
</section>
);
}
// ✅ Right: only the interactive bit is client
// ProjectSection stays as a Server Component
// Only the modal needs client state
"use client";
export function ProjectModal({ project }) {
const [open, setOpen] = useState(false);
// ...
}The waterfall problem nobody talks about enough
The biggest performance killer I've found in production Next.js apps isn't render strategy — it's request waterfalls. This is where the browser has to wait for one thing before it can start the next.
The classic waterfall: page loads HTML, HTML triggers JS bundle download, JS executes and then fires the actual data fetch. The user sees a loading spinner while all three of those things happen sequentially. On a slow connection, that's easily 2-3 seconds before any real content.
Server Components eliminate this entirely for data that doesn't need client interaction. The data fetch happens on the server during render — the HTML that arrives in the browser already has the content. Zero waterfall.
When you do need client-side fetches, parallel them aggressively. React Query's useQueries, or Promise.all on the server, is often the difference between 100ms and 400ms:
// ❌ Sequential — each waits for the previous const user = await getUser(id); const posts = await getPosts(user.id); const comments = await getComments(posts[0].id); // ✅ Parallel — all fire at once, ~3x faster const [user, posts] = await Promise.all([ getUser(id), getPosts(id), ]); const comments = await getComments(posts[0].id); // this one genuinely depends on posts
Image optimization — the part people get wrong
next/image is good and you should use it. But the reason most people use it (automatic WebP conversion) isn't the main reason it helps. The main reason is sizes.
If you add next/image without setting sizes, the browser downloads a full-resolution image for every viewport. A 2400px hero image gets sent to a 375px phone. sizes tells the browser which image size to download based on viewport:
// ❌ Missing sizes — always downloads the largest image
<Image src="/hero.jpg" width={1200} height={600} alt="Hero" />
// ✅ Correct — browser picks the right size for the viewport
<Image
src="/hero.jpg"
width={1200}
height={600}
alt="Hero"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 1200px"
/>On a mobile device, this is often a 4-8x reduction in image download size. Nothing else you do will save as much bandwidth.
One more: use priority on the largest above-the-fold image. Without it, the browser discovers the image late (after parsing JS) and LCP tanks. With it, the image preload starts with the HTML.
Bundle size — what actually matters
Run @next/bundle-analyzer and look at the output seriously. The things I consistently find:
- Date libraries.
moment.jsis 67KB gzipped.date-fnsis tree-shakeable.dayjsis 2KB. If you're formatting dates, you don't need moment. - Icon libraries imported wrong.
import { ArrowRight } from 'lucide-react'is fine (tree-shaken).import * as Icons from 'lucide-react'pulls in all 1000+ icons. - Importing from index files. Some packages don't tree-shake from the index. Import directly from the subpath (
lodash/debounceinstead oflodash). - Unused dependencies staying in the client bundle. Any import in a
"use client"file lands in the browser bundle. Move heavy logic to Server Components or API routes.
The metrics that actually tell you if it's working
Lighthouse scores are useful for finding obvious problems but terrible for measuring real-world improvement. The metrics I track in production:
- LCP (Largest Contentful Paint) — the render time of the biggest visible element. Target <2.5s. This is what users actually perceive as "page loaded."
- TTFB (Time to First Byte) — how long the server takes to start responding. If this is >800ms, you have a server-side problem (slow DB query, cold start, no caching). No amount of frontend optimization fixes high TTFB.
- INP (Interaction to Next Paint) — replaced FID as the interaction metric. Measures how long the browser takes to respond to clicks/taps. >200ms feels sluggish.
Use Vercel Analytics or a Real User Monitoring (RUM) tool to capture these from actual users, not a synthetic test environment. Lighthouse on your M3 MacBook tells you what your MacBook experiences. RUM tells you what your users in slower markets experience. They're often very different numbers.
The one thing that moves the needle most
After all of this: if you have to pick one thing, get your static/dynamic caching story right. A page that serves from the Full Route Cache loads in ~10ms from CDN. A page that hits your database on every request takes 200-800ms minimum. That gap is larger than any frontend optimization you could possibly do.
Figure out which parts of your app change on every request (user-specific data, real-time prices) and which parts change rarely (blog posts, product listings, marketing pages). Cache the latter aggressively. Personalize only what genuinely needs it.