The Starting Point
A Next.js 15 app with decent code, no major bugs, but poor Core Web Vitals: LCP at 5.2s, INP at 380ms. Here are the 7 changes that got it to LCP 1.4s and INP 65ms.
1. Move Data Fetching to Server Components
The biggest win. Client components that fetch data on load add a waterfall:
Browser → HTML → JS bundle → component mounts → fetch → render
Server Components collapse this:
Browser → HTML (already has data) → render
// Before: Client component with useEffect
'use client'
export default function ProductList() {
const [products, setProducts] = useState([])
useEffect(() => {
fetch('/api/products').then(r => r.json()).then(setProducts)
}, [])
return <Grid items={products} />
}
// After: Server component — data fetched at build/request time
export default async function ProductList() {
const products = await db.products.findMany()
return <Grid items={products} />
}
Impact: LCP dropped from 5.2s to 2.8s — eliminated an entire render waterfall.
2. Use next/image Correctly
Next.js's Image component handles optimization, but only if you use it right:
import Image from 'next/image'
// Wrong: no priority, unknown size
<Image src="/hero.jpg" alt="Hero" />
// Right: priority for above-fold, explicit dimensions
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
priority // <-- critical: preloads this image
quality={85}
/>
The priority prop is the key: it adds <link rel="preload"> for the image and removes loading="lazy".
Impact: LCP from 2.8s to 1.8s.
3. Lazy Load Below-the-Fold Components
Heavy components below the fold shouldn't block initial load:
import dynamic from 'next/dynamic'
// Lazy load with no SSR for browser-only heavy components
const HeavyChart = dynamic(() => import('./HeavyChart'), {
loading: () => <ChartSkeleton />,
ssr: false,
})
// Lazy load for components below the fold
const ReviewsSection = dynamic(() => import('./ReviewsSection'))
Impact: Bundle size reduced by 40KB gzipped, TTI improved.
4. Optimize Fonts with next/font
Google Fonts loaded via <link> block rendering. next/font downloads and self-hosts fonts, eliminating the external request:
// app/layout.tsx
import { Inter } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap',
preload: true,
})
export default function Layout({ children }) {
return (
<html className={inter.className}>
{children}
</html>
)
}
Impact: Eliminated 200ms font load time, reduced CLS from 0.18 to 0.04.
5. Add Proper Caching Headers
Next.js App Router has granular caching control:
// Static data — cache until revalidated
export const revalidate = 3600 // revalidate every hour
// Dynamic data — no cache
export const dynamic = 'force-dynamic'
// Per-fetch caching
const products = await fetch('/api/products', {
next: { revalidate: 300 } // cache for 5 minutes
})
For API routes:
export async function GET() {
const data = await getStaticData()
return Response.json(data, {
headers: {
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400'
}
})
}
Impact: Repeat visits went from 1.4s LCP to 0.3s (served from cache/CDN).
6. Reduce Client-Side JavaScript
Audit what's in your bundle:
# Analyze bundle
ANALYZE=true next build
Common culprits:
- Large date libraries (
moment.js→date-fnsorTemporal) - Full icon libraries (import only what you use)
- Unused
'use client'components that could be Server Components
// Before: entire icon library in client bundle
import { FiArrowRight, FiCheck } from 'react-icons/fi'
// After: direct import
import FiArrowRight from 'react-icons/fi/FiArrowRight'
import FiCheck from 'react-icons/fi/FiCheck'
Impact: 60KB JS reduction → INP from 380ms to 95ms.
7. Use Partial Prerendering (PPR)
Next.js 15 introduced Partial Prerendering: static shell served instantly, dynamic parts streamed in.
// app/page.tsx
import { Suspense } from 'react'
export default function Page() {
return (
<main>
{/* Static — served immediately from edge */}
<StaticHero />
<StaticNav />
{/* Dynamic — streams in after static shell */}
<Suspense fallback={<ProductSkeleton />}>
<DynamicProductList />
</Suspense>
</main>
)
}
// next.config.js
module.exports = {
experimental: { ppr: true }
}
Impact: LCP from 1.8s to 1.4s — users see static content instantly.
Key Takeaways
- Server Components — biggest win for data-heavy pages
next/imagewithpriority— critical for LCP- Dynamic imports — lazy load everything below the fold
next/font— self-host fonts, eliminate external requests- Caching — aggressive caching is free performance
- Bundle analysis — audit and remove heavy dependencies
- PPR — stream dynamic content after the static shell