•12 min read

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

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

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.


Audio Briefing
0:00 / 0:00

Understanding the Metrics That Matter

Before optimizing, know what you're measuring. The Core Web Vitals are the industry standard Google uses in rankings:

MetricWhat it measuresGood thresholdImpact
LCPLargest Contentful Paint — when main content appears< 2.5sPerceived load speed
INPInteraction to Next Paint — responsiveness< 200msUser interactivity
CLSCumulative Layout Shift — visual stability< 0.1Visual jank / trust
FIDFirst Input Delay — first click/keypress latency< 100msFirst 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.


Advertisement

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"
    />
  );
}

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. omit cyrillic if unused)
  • CSS variable for easy Tailwind integration

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 window or 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

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-PatternProblemBetter 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 1Use named imports: import { ChevronRight } from 'lucide-react'
Heavy chart libs at top levelNever used above foldDynamic import as shown in step 3

Advertisement

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

CheckStatusImpact
Using <Image> not <img> everywherepublishedHigh — LCP + CLS
Fonts via next/font, no external stylesheetspublishedHigh — FCP + CLS
No un-split heavy libs in entry bundledraftHigh — TTI + TBT
Server Components > Client ComponentsdraftHigh — Total JS
Parallel data fetching with Promise.alldraftMedium — First load time
Link prefetching tuned for mobiledraftMedium — 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
  1. Replace every <img> with <Image> — check for missing width/height
  2. Move font loading to next/font
  3. Run ANALYZE=true npm run build and hunt the biggest chunks
  4. 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.

You Might Also Like

Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement