Next.js apps that feel slow often share the same root causes: unoptimized images, blocking JavaScript, large bundles, and missing caching headers. This guide covers the practical fixes that move the needle on Core Web Vitals.
Understanding Core Web Vitals
Google uses three metrics to grade your site's user experience:
- LCP (Largest Contentful Paint): Load time for the main content. Target < 2.5s
- FID/INP (Interaction to Next Paint): Response time to user input. Target < 200ms
- CLS (Cumulative Layout Shift): Visual stability. Target < 0.1
Next.js gives you tools to hit all three. Most teams don't use them.
Analyze Your Bundle First
Before optimizing anything, measure:
npm install --save-dev @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
This opens a visual breakdown of every byte in your bundle. Common culprits: moment.js (use date-fns), lodash (use lodash-es with tree shaking), full icon libraries (import only what you use).
Dynamic Imports for Code Splitting
Don't load code until it's needed:
import dynamic from 'next/dynamic'
// Heavy chart component -- loads only when rendered
const RevenueChart = dynamic(() => import('../components/RevenueChart'), {
loading: () => <div className='h-64 animate-pulse bg-gray-100 rounded' />,
ssr: false, // client-only component
})
// Modal -- loads only when opened
const CheckoutModal = dynamic(() => import('../components/CheckoutModal'))
Rule of thumb: any component > 50KB that isn't above the fold should be dynamic.
Image Optimization
The next/image component handles WebP conversion, responsive srcsets, and lazy loading automatically:
import Image from 'next/image'
// Hero image -- load eagerly (above fold)
<Image
src='/hero.jpg'
alt='Hero'
width={1200}
height={600}
priority // preloads this image
sizes='100vw'
/>
// Product card -- lazy load (below fold)
<Image
src={product.image}
alt={product.name}
width={400}
height={300}
sizes='(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw'
/>
The sizes prop is critical. Without it, Next.js serves a full-width image for every breakpoint.
Font Optimization
Google Fonts loaded naively block rendering. Use next/font:
import { Inter } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap',
preload: true,
})
export default function RootLayout({ children }) {
return (
<html className={inter.className}>
<body>{children}</body>
</html>
)
}
This self-hosts the font, eliminates the external DNS lookup, and prevents layout shift from fallback fonts.
Caching Strategies
Next.js 14 App Router introduces granular caching control:
// Static -- cached indefinitely, revalidated on deploy
const data = await fetch('https://api.example.com/products', {
cache: 'force-cache',
})
// ISR -- revalidate every 60 seconds
const data = await fetch('https://api.example.com/products', {
next: { revalidate: 60 },
})
// Dynamic -- never cached
const data = await fetch('https://api.example.com/user-data', {
cache: 'no-store',
})
Match your caching strategy to data freshness requirements. Product catalogs can cache for 60s. User dashboards should not.
Partial Prerendering (PPR)
Next.js 14 experimental feature that renders static shells instantly and streams dynamic content:
// next.config.js
module.exports = {
experimental: { ppr: true },
}
// app/dashboard/page.tsx
import { Suspense } from 'react'
export default function Dashboard() {
return (
<div>
<StaticNav /> {/* Renders instantly from cache */}
<Suspense fallback={<Skeleton />}>
<DynamicUserData /> {/* Streams in after static shell */}
</Suspense>
</div>
)
}
Avoid Common Performance Mistakes
Waterfall fetches: Fetch in parallel, not sequentially:
// Bad -- 3 sequential requests
const user = await getUser(id)
const orders = await getOrders(user.id)
const products = await getProducts(orders)
// Good -- parallel where possible
const [user, allOrders] = await Promise.all([
getUser(id),
getOrdersByUserId(id),
])
Rendering too much on the server: Move interactive components to the client, but keep data fetching on the server.
Not using use client boundaries wisely: Every use client component pulls its dependencies into the client bundle. Keep them small and leaf-level.
Measuring Impact
After each optimization, measure:
# Local measurement
npm run build && npm start
# Then use Chrome DevTools Lighthouse
# CI measurement
npx lhci autorun
Set performance budgets in your CI pipeline. A PR that adds 200KB to your bundle should fail the build.
The AI SaaS Starter Kit at whoffagents.com ships with all these optimizations pre-configured: next/image throughout, next/font for Inter, dynamic imports on modals and charts, and Lighthouse scores > 90 out of the box. $99 one-time.
Top comments (0)