Most Next.js performance problems are not mysterious. They are the same five or six mistakes showing up in different codebases — and once you know what to look for, each one has a fix you can ship in hours, not weeks. This guide covers the most common Next.js performance killers, how to diagnose each one, and exactly what to change to get your Lighthouse score above 90 before Monday.
Optimising without measuring is guessing. Before touching a single line of code, run these three diagnostics and note the numbers. You will run them again after each fix to confirm it worked.
ANALYZE=true next build after installing @next/bundle-analyzer. This shows exactly which packages are bloating your JS bundle.# Install bundle analyser
npm install @next/bundle-analyzer
# next.config.ts
import withBundleAnalyzer from '@next/bundle-analyzer'
const bundleAnalyzer = withBundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
})
export default bundleAnalyzer({
// your existing next config
})
Once you have your baseline numbers, work through the fixes below in order — each section targets one specific problem category.
Largest Contentful Paint (LCP) — the time until the main visible content loads — is the metric most Next.js apps fail hardest on. In 90% of cases, the culprit is images. Specifically: using <img> instead of Next.js's <Image>, not setting priority on above-the-fold images, and not sizing images correctly.
// ❌ BEFORE — kills LCP
<img src="/hero.jpg" alt="Hero" className="w-full" />
// ✅ AFTER — Next.js Image with priority
import Image from 'next/image'
<Image
src="/hero.jpg"
alt="Hero"
width={1408}
height={716}
priority // ← preloads this image, critical for LCP
quality={85} // ← 85 is visually indistinguishable from 100, 40% smaller
placeholder="blur"
blurDataURL="data:image/jpeg;base64,..."
/>
The priority prop is the single highest-impact change for LCP. It tells Next.js to preload the image before the page finishes parsing — moving it from "discovered late" to "fetched immediately." Add it to every image visible without scrolling. Do not add it to images below the fold — that just wastes bandwidth.
// next.config.ts — allow external image domains
const nextConfig = {
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'images.unsplash.com' },
{ protocol: 'https', hostname: 'cdn.yourdomain.com' },
],
formats: ['image/avif', 'image/webp'], // ← AVIF is 50% smaller than WebP
},
}
A large JS bundle means users download and parse megabytes of JavaScript before your page becomes interactive. The two most common causes: importing entire libraries when you only need one function, and not code-splitting large components.
// ❌ BEFORE — imports entire lodash (70KB+)
import _ from 'lodash'
const result = _.debounce(fn, 300)
// ✅ AFTER — imports only debounce (2KB)
import debounce from 'lodash/debounce'
const result = debounce(fn, 300)
// ❌ BEFORE — imports all of lucide-react
import * as Icons from 'lucide-react'
// ✅ AFTER — imports only what you use
import { Search, ChevronDown, X } from 'lucide-react'
import dynamic from 'next/dynamic'
// ❌ BEFORE — chart library loads on every page
import { LineChart } from 'recharts'
// ✅ AFTER — only loads when the component is actually rendered
const LineChart = dynamic(
() => import('recharts').then(mod => mod.LineChart),
{
loading: () => <div className="h-64 animate-pulse bg-gray-100 rounded" />,
ssr: false, // charts don't need server rendering
}
)
// ✅ Also lazy-load heavy modals, drawers, and editors
const RichTextEditor = dynamic(() => import('@/components/RichTextEditor'), {
ssr: false,
})
Next.js App Router's biggest performance feature is React Server Components — but most developers either avoid them out of habit or add 'use client' to everything, defeating the purpose entirely. Server Components render on the server and send HTML to the client. No JS sent to the browser. No hydration cost.
// ❌ BEFORE — entire page is client-side, ships all JS to browser
'use client'
export default function ProductPage({ product }) {
const [quantity, setQuantity] = useState(1)
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p> {/* static — doesn't need client */}
<QuantitySelector value={quantity} onChange={setQuantity} />
</div>
)
}
// ✅ AFTER — page is a Server Component, only the interactive bit is client
// app/products/[id]/page.tsx — Server Component (no 'use client')
export default async function ProductPage({ params }) {
const product = await getProduct(params.id) // fetch on server, no useEffect
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<QuantitySelector /> {/* this one child is 'use client' */}
</div>
)
}
// components/QuantitySelector.tsx
'use client' // ← 'use client' only where you need interactivity
export function QuantitySelector() {
const [quantity, setQuantity] = useState(1)
// ...
}
Cumulative Layout Shift (CLS) — the metric that measures how much the page jumps around while loading — is almost always caused by fonts loading late and changing text dimensions. Next.js has a built-in font system that eliminates this entirely.
// ❌ BEFORE — font loads late, page jumps
// In a <link> tag or @import in CSS — causes layout shift
// ✅ AFTER — Next.js font optimization
// app/layout.tsx
import { Inter, JetBrains_Mono } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
preload: true,
})
const mono = JetBrains_Mono({
subsets: ['latin'],
display: 'swap',
variable: '--font-mono',
weight: ['400', '500', '700'],
})
export default function RootLayout({ children }) {
return (
<html className={`${inter.variable} ${mono.variable}`}>
<body>{children}</body>
</html>
)
}
Next.js downloads Google Fonts at build time, self-hosts them, and injects font-display: optional — eliminating all network requests to Google's servers and all layout shift from font loading. Zero configuration beyond the import.
Data fetching patterns cause two distinct performance problems: waterfalls (sequential fetches that could be parallel) and over-fetching on the client (data that could come from the server at build time).
// ❌ BEFORE — sequential waterfall, each waits for the previous
const user = await getUser(id)
const posts = await getUserPosts(id) // waits for user
const friends = await getUserFriends(id) // waits for posts
// ✅ AFTER — all three fire simultaneously
const [user, posts, friends] = await Promise.all([
getUser(id),
getUserPosts(id),
getUserFriends(id),
])
// app/blog/[slug]/page.tsx
// Generate static pages at build time for all known slugs
export async function generateStaticParams() {
const posts = await getAllPosts()
return posts.map(post => ({ slug: post.slug }))
}
// This page is now a static HTML file — zero server cost, CDN-cached
export default async function BlogPost({ params }) {
const post = await getPost(params.slug)
return <Article post={post} />
}
Analytics scripts, chat widgets, and ad pixels loaded in the default way block your page from rendering. Next.js's <Script> component fixes this with one prop change.
import Script from 'next/script'
// ❌ BEFORE — blocks page render
<script src="https://analytics.example.com/script.js"></script>
// ✅ AFTER — loads after page is interactive
<Script
src="https://analytics.example.com/script.js"
strategy="afterInteractive" // options: beforeInteractive | afterInteractive | lazyOnload
/>
// ✅ For non-critical scripts (chat widgets, social embeds)
<Script
src="https://widget.example.com/chat.js"
strategy="lazyOnload" // loads during browser idle time
/>
| Fix | Impact | Time to Ship | Metric Improved |
|---|---|---|---|
Add priority to hero image |
Very High | 5 minutes | LCP |
| Switch <img> to <Image> | High | 1–2 hours | LCP, CLS |
| Migrate to next/font | High | 30 minutes | CLS, LCP |
| Script strategy="afterInteractive" | High | 30 minutes | FID, INP |
| Lazy-load heavy components | Medium-High | 2–3 hours | FCP, TTI |
| Fix named imports (tree-shaking) | Medium | 1–2 hours | Bundle size, TTI |
| Move data fetching to Server Components | High | Half day | TTFB, TTI |
| Parallel Promise.all fetches | Medium-High | 1 hour | TTFB, LCP |
priority to your hero image — single prop, biggest LCP improvement availableoutput: 'standalone' to next.config.ts — reduces Docker image size by 80% if you self-hostimages.formats: ['image/avif', 'image/webp'] — AVIF images are 50% smaller than JPEG at the same qualitynpx depcheck and uninstall anything not in usePerformance is not a feature you add at the end. It is an architecture decision you make at the beginning — but most of the damage from getting it wrong can be undone in a weekend if you know where to look.
Most Next.js apps are slow because of the same handful of well-understood problems. None of them require a rewrite. None of them require a new framework. They require knowing what to look for — and then spending a focused weekend applying fixes in priority order, measuring after each one.
Start with your hero image and priority. Run Lighthouse. Watch your LCP drop. Then work down the list. By Sunday evening, you will have a measurably faster application and a score that reflects the quality of the product underneath it.
For pre-optimized, production-ready React components that follow these performance principles by default, visit uidrop.dev/components. For color tools and animation resources, explore Color Lab and the Animations Library.
Recent Posts
Micro-Interactions: The Small Animations That Make Your UI Feel Premium
14 Jul 2026
Will AI Agents Like Claude Replace Developers? The Honest Answer Nobody Is Giving You
12 Jul 2026
How to Build a Design System in Next.js from Scratch
12 Jul 2026
NextGen by uidrop.dev: AI Landing Page Templates So Cinematic They Should Charge Admission
12 Jul 2026
Special Offer
Pro Components & Premium Themes
Production-ready UI kits for faster shipping.