Next.js performance Tips: From Good to Lighthouse-Perfect

Table of Contents
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.
Understanding the Metrics That Matter
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 |
Note on INP
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.
The Optimization Toolbox
The Mental Model
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.
1. Image Optimization — The #1 Win for LCP
Use the <Image> component from next/image, not the raw <img> tag. It does three things automatically:
- Auto WebP/AVIF conversion — smaller files with no quality loss
- Size/quality control — serve a 300px image on mobile, 1200px on desktop
- Layout shift prevention — requires width/height so the browser reserves the right space
import Image from 'next/image';
export function Hero() {
return (
<Image
src="/hero.jpg"
alt="Dashboard overview"
width={1200}
height={675}
priority
className="rounded-xl"
/>
);
}
Critical: Always provide width and height
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.
2. Font Optimization — Eliminate FOIT
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'],
});
What this gives you for free
- Self-hosted font files — no external request to Google
- Automatic
font-display: swap— text visible while font loads - Subset by
subsets— drop unused glyphs (e.g. omitcyrillicif unused) - CSS variable for easy Tailwind integration
import localFont from 'next/font/local';
const geist = localFont({
src: './fonts/GeistVF.woff2',
display: 'swap',
variable: '--font-geist',
});
When to use local fonts
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.
3. Dynamic Imports — Split Heavy Code
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 />;
}
When to skip SSR (`ssr: false`)
- Charts/graphs that rely on
windowor browser APIs - Heavy animation libraries (GSAP, Framer Motion)
- Third-party widgets (Google Maps, Stripe Elements)
- Dark mode toggles — wait, this is cheap enough to render on server
Without 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.
4. Server Components — Default to Not Shipping JS
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 |
Caching and Data Flow
Next.js Caching Layers
Next.js has multiple caching mechanisms. Understanding the difference prevents 90% of "why is my data stale?" confusion.
Request Memoization
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.
Data Cache
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'
});
Full Route Cache
Static pages are rendered at build time and cached as HTML. A static page serves instantly—no server computation at request time.
Router Cache (Client-side)
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
}
Why Server Actions?
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>
);
}
How SSR + Streaming work together
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.
Advanced Bundle Analysis
Know What You're Shipping
A single unoptimized import can add 200KB+ to your client bundle. The bundle analyzer makes the invisible visible.
Install @next/bundle-analyzer
Add it to your project. It generates an interactive treemap of every JS chunk:
npm install -D @next/bundle-analyzer
next.config.js Setup
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({});
Run the Analyzer
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.
Common Victims
| 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 |
But Wait, There's More
<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
};
Optimization Checklist
| 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 |
Measuring Your Results
Don't guess. Measure. Run these in production and track the scores over time:
Lighthouse CI
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
Vercel Analytics
Built into your deployment
Real Web Vitals from actual users. Shows the actual distribution: p75, p90 LCP — more honest than lab metrics.
SpeedCurve
Continuous performance monitoring
Tracks filmstrip views + metrics over time. Great for correlating deploys with performance changes.
PageSpeed Insights
Free official Google tool
Quick lab + field data. Good for initial audits and capturing "before" screenshots for your PR.
TL;DR
The top 4 things to do today
- Replace every
<img>with<Image>— check for missing width/height - Move font loading to
next/font - Run
ANALYZE=true npm run buildand hunt the biggest chunks - Find one dynamic candidate in your codebase and convert to
next/dynamic
These 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.
- Zustand vs Jotai State Management Comparison
- Next.js App Router Dynamic Revalidation Guide
- Custom React Hook Performance Optimization Patterns
You Might Also Like
Free In-Browser Developer Tools
Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.
Related Articles

React Server Components vs Client Components: A Deep Dive
Understand the architectural differences between React Server Components (RSC) and Client Components. Learn when to use each for optimal performance and interactivity in modern web development.
Read more
Next.js App Router Dynamic Revalidation Guide
Master Next.js App Router caching architecture: fetch request memoization, data cache invalidation with revalidateTag, and on-demand ISR revalidation.
Read more
Demystifying Next.js 14 App Router Caching: Debugging Stale Data
Master Next.js App Router caching layers: Request Memoization, Data Cache, Full Route Cache, and Router Cache to prevent unexpected stale data bugs.
Read more