DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Next.js Performance Optimization: Core Web Vitals, Bundle Analysis, and Image Loading

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
Enter fullscreen mode Exit fullscreen mode
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
})

module.exports = withBundleAnalyzer({
  // your config
})
Enter fullscreen mode Exit fullscreen mode
ANALYZE=true npm run build
Enter fullscreen mode Exit fullscreen mode

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'))
Enter fullscreen mode Exit fullscreen mode

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'
/>
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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',
})
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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),
])
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)