Every slow React app I've inherited was slow for the same reason: performance was treated as a thing you fix later. Someone runs Lighthouse a week before launch, sees a red number, and starts bolting on lazy-loading and memoization in a panic. It rarely works, because by then the slowness is structural. The app is slow the way a building is cold when you forgot to insulate it — you can't fix that with a bigger heater.
After three years of shipping Next.js interfaces, the biggest shift in how I work is that I now treat speed as a design constraint, not a final polish. Here's what that actually means in practice.
Start from the client/server boundary
The single most important decision in a modern Next.js app is which components run on the server and which ship JavaScript to the browser. The App Router makes server components the default, and that default is correct far more often than people trust it to be. A component that renders data and never uses state, effects, or event handlers has no business being a client component — shipping it to the browser is pure waste.
My rule of thumb: keep the interactive leaves of the tree as client components, and keep everything above them on the server. A product page can be a server component that fetches and renders the whole layout; only the "add to cart" button and the image gallery need to be interactive. When you invert that — making the page a client component and passing data down — you drag the entire subtree into the browser bundle.
The cheapest JavaScript is the JavaScript you never send.
This is the lever with the biggest payoff, and it costs nothing but attention. Before I make a component a client component, I ask whether it genuinely needs the browser. Usually a small wrapper around one interactive element does the job, and the expensive rendering stays on the server.
Hydration is the tax nobody budgets for
Server-rendered HTML shows up fast, and that's the metric everyone celebrates. But the page isn't usable until React has hydrated it — attached its event listeners and rebuilt its component tree in the browser. On a mid-range Android phone over a patchy connection, that gap between "looks ready" and "responds to taps" can be seconds, and it's invisible in the demo you run on a fast laptop.
The fix is to have less to hydrate. Every client component you eliminate shrinks the hydration work. Beyond that, be deliberate about what loads eagerly. A heavy charting library or a rich text editor doesn't need to be in the initial bundle if it lives below the fold or behind an interaction. Dynamic imports with a sensible loading state let you defer that cost until the user actually needs it.
Images are still where most of the weight is
It's unglamorous, but on most sites the images outweigh the code by an order of magnitude. Next.js gives you an Image component that handles responsive sizing, modern formats and lazy-loading almost for free, and not using it is leaving performance on the table. The two things people forget:
- Always set width and height (or use
fillwith a sized container). Layout shift from unsized images is one of the most common Core Web Vitals failures, and it's entirely preventable. - Mark your hero image as priority. The largest image above the fold is usually your Largest Contentful Paint element. Letting it lazy-load means you're deliberately delaying the metric Google watches most closely.
Fonts: the invisible render-blocker
Custom fonts are the most common cause of a page that renders and then jumps a moment later. Using next/font to self-host and preload your fonts removes the extra round-trip to a font CDN and lets you control the fallback behaviour. Pick a font-display strategy on purpose: swap shows fallback text immediately and swaps when the real font arrives, which is almost always what you want for body text. Match the fallback's metrics to your web font and the swap becomes nearly invisible.
Fetch on the server, cache with intent
Fetching data in a client-side effect — the classic useEffect that fires after the component mounts — creates a waterfall: the browser loads the page, runs the JavaScript, then starts the request. In the App Router you can fetch directly in a server component, so the data is already in the HTML when it arrives. That single change removes an entire round-trip from the critical path.
Caching is where Next.js gives you real leverage, and also where you can quietly break things. Be explicit about what's static, what revalidates on a timer, and what has to be dynamic per request. The mistake I see most is making everything dynamic "to be safe," which throws away the whole benefit of the framework. Decide, per route, how fresh the data truly needs to be — most of the time "a few minutes stale" is completely fine and enormously faster.
Measure on the device your users actually have
The trap I fell into early was tuning performance on my own machine — a fast laptop on office fibre. Everything felt instant, so everything was fine. Then I looked at real-world field data and saw a completely different story from phones on mobile networks.
Now I keep two habits. First, I throttle the CPU and network in dev tools to something closer to a mid-range phone; if it's tolerable there, it's excellent for everyone else. Second, I pay attention to field metrics, not just lab scores — the numbers from real visitors, which capture the messy reality of devices and connections you'd never test manually. A perfect Lighthouse score on your laptop means very little if your actual users are on the slow path.
The short version
If I compress three years into a handful of rules: keep components on the server unless they truly need the browser; ship less JavaScript so there's less to hydrate; treat images and fonts as first-class performance concerns rather than afterthoughts; fetch data where it's cheapest and cache it on purpose; and always measure on a slow device. None of these are clever tricks. They're just decisions made early, and then defended when the deadline tempts you to cut them.
That last part is the real skill. Anyone can make a fast app in a demo. Keeping it fast through six months of feature pressure is the actual job.