
Performance is not a checklist - it's a continuous practice. A fast site improves SEO, reduces bounce rate, and directly increases conversions. Next.js removes most of the friction, but you still have to understand why each feature matters and when to reach for it.
Before optimizing, know what you're measuring. The Core Web Vitals are the industry standard Google uses in rankings:
| Metric | What it measures | Good threshold | Impact |
|---|---|---|---|
| LCP | Largest Contentful Paint - when main content appears | < 2.5s | Perceived load speed |
| INP | Interaction to Next Paint - responsiveness | < 200ms | User interactivity |
| CLS | Cumulative Layout Shift - visual stability | < 0.1 | Visual jank / trust |
| FID | First Input Delay - first click/keypress latency | < 100ms | First interaction feel |
FID was replaced by INP in March 2024. INP measures responsiveness across the entire page lifecycle, not just the first input. That's a bigger target - and meaningful optimization helps both.
Every performance technique in Next.js maps to one of two goals: get the first pixels on screen faster or send less JavaScript to the client. Keep that in mind and the "why" becomes obvious.
Use the <Image> component from next/image, not the raw <img> tag. It does three things automatically:
import Image from 'next/image';
export function Hero() {
return (
<Image
src="/hero.jpg"
alt="Dashboard overview"
width={1200}
height={675}
priority
className="rounded-xl"
/>
);
}
Without both, Next.js cannot reserve layout space, and CLS suffers. Use the real intrinsic dimensions of your image. If in doubt, check the image file in your OS file browser or use an image editor.
Use the next/font module. It downloads fonts at build time and serves them locally - zero third-party requests, zero layout shift.
import { Inter, Roboto_Mono } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap', // ✅ Shows fallback text instantly
variable: '--font-inter',
});
const robotoMono = Roboto_Mono({
subsets: ['latin'],
weight: ['400', '700'],
});
font-display: swap - text visible while font loadssubsets - drop unused glyphs (e.g. omit cyrillic if unused)import localFont from 'next/font/local';
const geist = localFont({
src: './fonts/GeistVF.woff2',
display: 'swap',
variable: '--font-geist',
});
If you already have font files (purchased or self-hosted), next/font/local gives you the same zero-shift benefits without going through Google's CDN.
Don't load components that aren't visible on initial paint. Use next/dynamic to code-split at the component level:
// Heavy chart library: ~200KB gzipped
import dynamic from 'next/dynamic';
// Load it only when needed (client-side, no SSR)
const RevenueChart = dynamic(
() => import('@/components/RevenueChart'),
{
ssr: false, // ✅ Skip server rendering for this component
loading: () => <Skeleton />, // ✅ Show placeholder instantly
}
);
export function Dashboard() {
return <RevenueChart />;
}
window or browser APIsWithout dynamic imports, bundlers may group everything into a single JS file. With dynamic imports, the component loads in a separate chunk fetched only when needed. This is the single biggest bundle size reducer for medium-to-large apps.
Use the bundle analyzer (step 6) to find your heaviest components. Typically: rich text editors, charts, 3D viewers, auth widgets. Don't over-split - dynamic imports add a network round-trip. Split components that prevent meaningful initial JS download.
In the App Router, all components are Server Components by default. This is your biggest performance lever - you get the results of rendering without sending that component's JS to the browser.
// This entire file runs on the server
// → Zero JavaScript sent to the browser for this component
async function BlogPost({ slug }: { slug: string }) {
const post = await db.posts.findUnique({ where: { slug } });
return (
<article>
<h1>{post.title}</h1>
<MDXContent source={post.content} />
</article>
);
}
Need a useState, useEffect, onClick, or window?
└── Yes → Add "use client" (Client Component)
└── No → It's already a Server Component (best default)
Server Component delivers:
✅ HTML directly to browser (fast FCP)
✅ Data fetching on server (no client fetch waterfalls)
✅ Zero JS bundle cost for that component
| Scenario | Should be | Notes |
|---|---|---|
| Page layout shell | Server | No interactivity needed |
| Data-fetching wrapper | Server | Run queries on server |
| Button with onClick | Client | Minimal boundary |
| Form with useState | Client | Keep form small, lift state up |
| Theme toggle button | Client | Only the button, not the page |
Next.js has multiple caching mechanisms. Understanding the difference prevents 90% of "why is my data stale?" confusion.
When you call await fetch(...) inside a Server Component, Next.js deduplicates identical requests within a single render. A second identical request in the same render returns the cached result without hitting the network again.
Next.js extends fetch with a built-in data cache. Results are persisted (by default, forever-ish). Subsequent renders across requests read from the cache.
// Force revalidation every hour
fetch('https://api.example.com/data', {
next: { revalidate: 3600 } // seconds
});
// No revalidation = cache until you deploy
fetch('https://api.example.com/data');
// Always fresh (no caching)
fetch('https://api.example.com/data', {
cache: 'no-store'
});
Static pages are rendered at build time and cached as HTML. A static page serves instantly - no server computation at request time.
The client-side router cache stores rendered pages in memory (and localStorage for back/forward navigation). Navigating back feels instant because the page loads from cache.
Server Actions blur the line between client and server. Call them from Client Components, but they execute on the server with full database access.
'use server';
import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
await db.posts.create({ data: { title } });
revalidatePath('/posts'); // Invalidate the posts cache
}
Before Server Actions, "create post" required building a full API route, handling auth in a separate layer, and revalidating on the client with messy cache-tag logic. Server Actions let you write server-side code inline - no API boilerplate, no client-side state management for the mutation itself.
Instead of waiting for all data to load, stream pieces of HTML as they become ready:
// Slow data component - this is the bottleneck
async function SlowChart() {
const data = await fetch('/api/slow-data').then(r => r.json());
return <Results data={data} />;
}
// Skeleton shows immediately, content streams in
export default async function DashboardPage() {
return (
<div>
<header>Dashboard</header> {/* ✅ Instant */}
<RecentActivity /> {/* ✅ Fast */}
<Suspense fallback={<ChartSkeleton />}> {/* Shows skeleton */}
<SlowChart /> {/* Streams when ready */}
</Suspense>
</div>
);
}
Server Components still run on the server. The difference with Suspense is that the response streams chunks as they're ready. The shell sends immediately, slow data sends later. The user sees content progressively instead of waiting for one big HTML response.
A single unoptimized import can add 200KB+ to your client bundle. The bundle analyzer makes the invisible visible.
Add it to your project. It generates an interactive treemap of every JS chunk:
npm install -D @next/bundle-analyzer
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({});
ANALYZE=true npm run build
Open the URL it prints (typically http://localhost:3000/_next/analyze/...). You'll see a treemap where bigger boxes = bigger bundles. Hover to see what's taking space.
| Anti-Pattern | Problem | Better approach |
|---|---|---|
import { format } from 'date-fns' | Pulls in the whole library (~100+ functions) | Use date-fns tree-shaking + specific imports, or dayjs |
import { debounce } from 'lodash' | Whole lodash (~500KB) | Use lodash-es or write a 3-line debounce |
import * as Icons from 'lucide-react' | Pulls every icon but uses 1 | Use named imports: import { ChevronRight } from 'lucide-react' |
| Heavy chart libs at top level | Never used above fold | Dynamic import as shown in step 3 |
<Link> from next/link automatically prefetches pages linked in the viewport (when the user is on a fast connection):
import Link from 'next/link';
<Link href="/en/blog/nextjs-performance"> {/* ✅ Prefetched when visible */}
Read more
</Link>
<Link href="/heavy-page" prefetch={false}> {/* ⬇️ Skip prefetch */}
Heavy page (saves bandwidth on mobile)
</Link>
Fetching data in parallel cuts waterfalls dramatically:
// ❌ Sequential: 200ms + 300ms = 500ms total
async function SlowPage() {
const user = await fetch('/api/user').then(r => r.json()); // 200ms
const posts = await fetch('/api/posts').then(r => r.json()); // 300ms
return <Dashboard user={user} posts={posts} />;
}
// ✅ Parallel: max(200ms, 300ms) = 300ms total
async function FastPage() {
const [user, posts] = await Promise.all([
fetch('/api/user').then(r => r.json()),
fetch('/api/posts').then(r => r.json()),
]);
return <Dashboard user={user} posts={posts} />;
}
Control rendering behavior per-page with route segment config:
export const dynamic = 'force-static'; // Always static, even in dev
export const revalidate = 3600; // Revalidate every hour
export const fetchCache = 'force-no-store'; // Never use data cache
// For one-off routes without creating a separate layout:
export const metadata = {
title: 'My Page',
robots: { index: false }, // Prevent indexing this page
};
| Check | Status | Impact |
|---|---|---|
Using <Image> not <img> everywhere | published | High - LCP + CLS |
Fonts via next/font, no external stylesheets | published | High - FCP + CLS |
| No un-split heavy libs in entry bundle | draft | High - TTI + TBT |
| Server Components > Client Components | draft | High - Total JS |
Parallel data fetching with Promise.all | draft | Medium - First load time |
| Link prefetching tuned for mobile | draft | Medium - Navigation speed |
Don't guess. Measure. Run these in production and track the scores over time:
Automated performance testing in CI
Set performance budgets. CI fails if a PR drops LCP below your threshold. Crucial for preventing regression.
lighthouse https://yoursite.com --budget-path=budget.json
Built into your deployment
Real Web Vitals from actual users. Shows the actual distribution: p75, p90 LCP - more honest than lab metrics.
Continuous performance monitoring
Tracks filmstrip views + metrics over time. Great for correlating deploys with performance changes.
Free official Google tool
Quick lab + field data. Good for initial audits and capturing "before" screenshots for your PR.
<img> with <Image> - check for missing width/heightnext/fontANALYZE=true npm run build and hunt the biggest chunksnext/dynamicThese four actions routinely shave 30-50% off bundle size and bring LCP from 6s down to under 2s.
Related Reading: For ensuring code quality and performance in large monorepos, see Vitest Monorepo Unit Testing Performance.
A Next.js application is experiencing poor Lighthouse scores, specifically an elevated LCP (Largest Contentful Paint) metric. The development team currently uses standard `<img>` tags for hero banners. What is the most effective first step to improve LCP?
Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

The comprehensive production guide to Next.js App Router: Server Components boundaries, advanced caching, streaming with Suspense, Server Actions, connection pooling, and enterprise route architecture.
Read more
Deep dive into Next.js 15 PPR: hybrid streaming wire format, React 19 Flight protocol, and eliminating hydration layout shifts.
Read more
How Next.js 15 Partial Prerendering streams dynamic components inside static shells: Suspense architecture, TTFB drops, and edge caching.
Read more