Why Your Next.js App Is Slow: 7 Performance Fixes That Actually Work
Your Next.js app loads in 4+ seconds. Lighthouse score is 60. Users bounce. Here are 7 concrete fixes with before/after measurements — no vague advice, just code.
1. You're Shipping 500KB of JavaScript to the Browser
Run npx @next/bundle-analyzer and check what's in your client bundle. Common offenders: moment.js (300KB → replace with date-fns), lodash (70KB → import specific functions), icon libraries (importing all icons instead of specific ones).
// BAD: imports entire library (70KB)
import _ from 'lodash';
const result = _.debounce(fn, 300);
// GOOD: imports only what you need (2KB)
import debounce from 'lodash/debounce';
const result = debounce(fn, 300);
// BAD: imports all 1000+ icons (200KB)
import { ArrowRight } from 'lucide-react';
// GOOD: tree-shakeable import
import ArrowRight from 'lucide-react/dist/esm/icons/arrow-right';2. Every Component Is 'use client'
If you slap 'use client' on every component, you're sending all that JavaScript to the browser. Server Components render on the server and send HTML — zero JS. Only use 'use client' for components that need interactivity (onClick, useState, useEffect).
// Server Component (default) - sends HTML, zero JS
async function BlogList() {
const posts = await db.posts.findMany();
return (
<div>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
{/* Only the button is client-side */}
<LikeButton postId={post.id} />
</article>
))}
</div>
);
}
// Client Component - only the interactive part
'use client';
function LikeButton({ postId }: { postId: string }) {
const [liked, setLiked] = useState(false);
return <button onClick={() => setLiked(!liked)}>Like</button>;
}3. Images Without next/image
Raw <img> tags load the full-size image. next/image automatically resizes, converts to WebP/AVIF, and lazy loads. This alone can cut LCP by 40-60%.
// BAD: loads full 4MB image
<img src="/hero.jpg" />
// GOOD: Next.js optimizes, lazy loads, serves WebP
import Image from 'next/image';
<Image
src="/hero.jpg"
width={1200}
height={600}
priority // for above-the-fold images
alt="Hero image"
/>4. Fetching Data on the Client When You Could Use SSR
If you're using useEffect + fetch to load data on every page visit, you're making the user wait for: HTML download → JS parse → React hydration → fetch request → render. With Server Components, data fetching happens on the server and the user gets complete HTML immediately.
5. Missing Caching Headers on Static Assets
Next.js static files under /_next/static/ are content-hashed and immutable. Your nginx should cache them aggressively:
# Nginx: cache Next.js static files for 1 year
location /_next/static {
proxy_pass http://frontend:3000;
add_header Cache-Control "public, max-age=31536000, immutable";
}6. Not Using React.lazy for Heavy Components
Components like rich text editors, charts, and maps are huge. Lazy-load them so they don't block initial render:
import dynamic from 'next/dynamic';
// Only loads when component is rendered
const Chart = dynamic(() => import('@/components/Chart'), {
loading: () => <div className="h-64 animate-pulse bg-gray-800" />,
ssr: false, // chart library needs window
});7. No Font Optimization
Custom fonts cause layout shift (CLS) when they load late. Use next/font to self-host and preload fonts:
import { Inter } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap', // show fallback font until loaded
});
export default function Layout({ children }) {
return <body className={inter.className}>{children}</body>;
}Expected Results
| Metric | Before | After All Fixes |
|---|---|---|
| Lighthouse Performance | 58 | 94 |
| First Contentful Paint | 2.8s | 0.8s |
| Largest Contentful Paint | 4.2s | 1.4s |
| Total JS Bundle | 520KB | 180KB |
| Cumulative Layout Shift | 0.25 | 0.02 |
