Next.js Performance Patterns I Use on Every Project
Every Next.js project I've shipped has ended up applying the same handful of performance patterns — and every slow Next.js app I've been asked to look at was missing the same ones. This is the checklist I now apply by default, roughly in order of impact per hour of effort.
Why Performance Matters
Speed is a feature. Next.js gives you a lot out of the box, but it is still easy to ship a massive bundle if you are not careful — the framework optimizes what you hand it, and it will happily hand a megabyte of hydration JavaScript to a page that renders a list.
The discipline that makes everything else work: measure before touching anything. next build prints per-route bundle sizes, and @next/bundle-analyzer shows you what's inside them. Every pattern below is worthless if you apply it to a route that was never the problem.
Server Components by Default
Always default to Server Components. If a component does not need useState, useEffect, or DOM event listeners, it should render on the server — zero JavaScript shipped, data fetched next to the database instead of over a client waterfall.
// This component ships zero JavaScript to the client
export default async function TopPosts() {
const posts = await db.query('SELECT * FROM posts LIMIT 5');
return (
<ul>
{posts.map(post => <li key={post.id}>{post.title}</li>)}
</ul>
);
}The real pattern isn't the component — it's where you draw the client boundary. 'use client' doesn't mark one component as client-side; it marks everything it imports as client-side. One careless directive at the top of a layout drags your whole tree into the bundle.
Pro Tip: If you need a small slice of interactivity (like a "Like" button), create a microscopic Client Component just for that button and pass the rest through as
children. Children rendered inside a client component can still be server components — the boundary is about imports, not nesting.
// LikeButton.tsx — the ONLY client code on the page
'use client';
export function LikeButton({ postId }: { postId: string }) {
const [liked, setLiked] = useState(false);
return <button onClick={() => setLiked(!liked)}>{liked ? '♥' : '♡'}</button>;
}Treat the LCP Image Differently From Every Other Image
next/image handles resizing, modern formats, and lazy loading — but it lazy-loads everything by default, including the hero image at the top of the page. That image is almost always your Largest Contentful Paint element, and lazy-loading it tells the browser to deprioritize the one resource users are waiting on.
// Above the fold — load eagerly, with high fetch priority
<Image src="/hero.jpg" alt="..." width={1200} height={630} priority />
// Everything below the fold — default lazy behavior is correct
<Image src="/section.jpg" alt="..." width={800} height={450} />One priority prop is routinely worth several hundred milliseconds of LCP. It's the single highest ROI line of code in this post.
Two supporting rules: always give images real width and height (that's what prevents layout shift), and compress source files before committing them — next/image optimizes what it serves, but social scrapers and RSS readers fetch your originals raw.
Load Fonts Like They're Render-Blocking — Because They Are
next/font self-hosts Google Fonts and preloads them, which is great — until you're preloading six weights of four families and they're all competing with your LCP image for bandwidth. My rules:
- Two families maximum for most projects; every family after that needs to justify itself.
- Only the weights actually used. Loading
['300','400','500','600','700']"to be safe" is five files where two would do. - Let
next/font's defaultfont-display: swapdo its job instead of blocking paint on custom fonts.
Dynamic Imports for the Heavy Tail
Charts, editors, maps, syntax highlighters — the components that are huge, interactive, and usually below the fold or behind a click. Don't pay for them at page load:
const Chart = dynamic(() => import('@/components/Chart'), {
loading: () => <ChartSkeleton />,
ssr: false,
});The related move is auditing dependencies for accidental weight: a date library imported for one format() call, a 90KB utility package used for debounce. The bundle analyzer makes these embarrassingly visible.
Cache at the Route Level, Stream the Rest
Most pages on most sites are static or nearly static — let Next.js render them at build time and revalidate on an interval instead of rendering per-request:
// Rebuilt at most once per hour, served from CDN in between
export const revalidate = 3600;For genuinely dynamic pages, <Suspense> boundaries let the static shell paint immediately while slow data streams in behind it. Users judge speed by when they see the page, not when the last byte arrives — streaming exploits that directly.
Third-Party Scripts Go Last
Analytics, chat widgets, and tag managers are the top cause of poor interactivity scores I see in the wild, and none of them deserve to compete with your application code. next/script with strategy="lazyOnload" for anything non-critical, and a periodic hard look at whether each script still earns its cost.
The Order I Apply These
- Run the bundle analyzer and fix the two worst routes — usually a misplaced
'use client'or a heavy dependency. - Add
priorityto the LCP image on key pages. - Cut font families and weights to what's actually used.
- Dynamic-import the heavy, below-the-fold components.
- Set
revalidateon everything that doesn't need per-request rendering. - Push third-party scripts to
lazyOnload.
Steps 1–3 typically take an afternoon and deliver most of the win. Everything after that is tuning — worth doing, but only once the fundamentals stop being the bottleneck.