A slow Next.js app isn't a framework problem -- it's usually a few fixable issues. Here's how to systematically make it fast.
Measure First
Don't optimize blindly:
# Analyze your bundle
npm install @next/bundle-analyzer
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true'
})
module.exports = withBundleAnalyzer({
// your config
})
ANALYZE=true npm run build
The bundle analyzer shows which libraries are taking up space. Usually one or two giant libraries account for 60% of the bundle.
Image Optimization
import Image from 'next/image'
// Good: Next.js Image handles resizing, lazy loading, and format conversion
<Image
src="/hero.jpg"
alt="Hero image"
width={1200}
height={600}
priority // For above-the-fold images
sizes="(max-width: 768px) 100vw, 50vw"
/>
// For external images, add domain to next.config.js
// images: { domains: ['cdn.example.com'] }
Use priority only for above-the-fold images. Everything else lazy-loads automatically.
Font Loading Without Flash
// app/layout.tsx
import { Inter } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap', // Show fallback font immediately, swap when loaded
variable: '--font-inter'
})
export default function Layout({ children }) {
return (
<html className={inter.variable}>
<body className="font-sans">{children}</body>
</html>
)
}
next/font downloads fonts at build time and self-hosts them -- no network request to Google at runtime, no FOUT (flash of unstyled text).
Code Splitting and Lazy Loading
import dynamic from 'next/dynamic'
// Lazy load heavy components
const ChartComponent = dynamic(
() => import('./Chart'),
{
loading: () => <div className="h-48 animate-pulse bg-surface rounded" />,
ssr: false // Don't render on server (for browser-only libraries)
}
)
// Load only when user interacts
const [showModal, setShowModal] = useState(false)
const HeavyModal = dynamic(() => import('./HeavyModal'))
return (
<>
<button onClick={() => setShowModal(true)}>Open</button>
{showModal && <HeavyModal />}
</>
)
Route Prefetching
Next.js prefetches routes in the viewport automatically. Control it explicitly:
import Link from 'next/link'
import { useRouter } from 'next/navigation'
// Prefetch on hover (default)
<Link href="/dashboard">Dashboard</Link>
// Disable prefetch for logged-out pages
<Link href="/premium" prefetch={false}>Premium</Link>
// Programmatic prefetch on hover
const router = useRouter()
<button onMouseEnter={() => router.prefetch('/checkout')}>
Continue to Checkout
</button>
Minimizing Client JS
// Every 'use client' adds to the JS bundle
// Before adding it, ask: does this need interactivity?
// Bad: made entire page a Client Component just for one button
'use client'
export default function ProductPage({ product }) {
const [count, setCount] = useState(1)
return (
<div>
<h1>{product.name}</h1> {/* Doesn't need client */}
<p>{product.description}</p> {/* Doesn't need client */}
<input value={count} onChange={e => setCount(+e.target.value)} />
</div>
)
}
// Good: only the interactive part is a Client Component
// ProductPage.tsx (Server Component)
export default function ProductPage({ product }) {
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<QuantitySelector /> {/* Only this is 'use client' */}
</div>
)
}
Core Web Vitals Targets
| Metric | Good | Needs Work | Poor |
|---|---|---|---|
| LCP (Largest Contentful Paint) | < 2.5s | 2.5-4s | > 4s |
| INP (Interaction to Next Paint) | < 200ms | 200-500ms | > 500ms |
| CLS (Cumulative Layout Shift) | < 0.1 | 0.1-0.25 | > 0.25 |
Fix LCP: Preload your hero image, use next/image with priority, reduce server response time.
Fix INP: Avoid long tasks on the main thread, defer non-critical JS, use web workers for heavy computation.
Fix CLS: Always specify image dimensions, reserve space for ads/dynamic content, avoid inserting content above existing content.
Vercel Speed Insights
// app/layout.tsx
import { SpeedInsights } from '@vercel/speed-insights/next'
export default function Layout({ children }) {
return (
<html>
<body>
{children}
<SpeedInsights />
</body>
</html>
)
}
Shows real user LCP, INP, and CLS from actual visitors -- much more accurate than lab tests.
The biggest wins usually come from: analyzing the bundle (often one misconfigured import adds 200KB), switching from <img> to next/image, and moving Client Components to the leaves of the component tree.