Next.js Performance Optimization — Core Web Vitals

Sanjeev SharmaSanjeev Sharma
5 min read

Advertisement

Next.js Performance Optimization — Core Web Vitals

Core Web Vitals measure real user experience. Optimizing for these metrics improves both user satisfaction and search rankings.

Core Web Vitals Explained

LCP (Largest Contentful Paint) — When main content appears

  • Target: < 2.5 seconds
  • Causes: Slow server response, render-blocking JS/CSS

FID (First Input Delay) — Response to user interaction

  • Target: < 100ms
  • Causes: Heavy main thread work

CLS (Cumulative Layout Shift) — Unexpected layout changes

  • Target: < 0.1
  • Causes: Unoptimized images, dynamic content

Measure Performance

Use Next.js analytics:

// app/layout.tsx
import { Analytics } from '@vercel/analytics/react'
import { SpeedInsights } from '@vercel/speed-insights/next'

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        <Analytics />
        <SpeedInsights />
      </body>
    </html>
  )
}

Check metrics at web.dev.

Optimize Images

Use next/image for automatic optimization:

import Image from 'next/image'

export default function Hero() {
  return (
    <Image
      src="/hero.jpg"
      alt="Hero"
      width={1200}
      height={600}
      priority // Loads immediately for LCP
      sizes="(max-width: 640px) 100vw, 75vw"
    />
  )
}

Optimize Fonts

import { Inter } from 'next/font/google'

const inter = Inter({ subsets: ['latin'], display: 'swap' })

export default function RootLayout({ children }) {
  return (
    <html className={inter.className}>
      <body>{children}</body>
    </html>
  )
}

Code Splitting

Next.js automatically code-splits. For manual control:

import dynamic from 'next/dynamic'
import { Suspense } from 'react'

const HeavyComponent = dynamic(() => import('@/components/heavy'), {
  loading: () => <div>Loading...</div>
})

export default function Page() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <HeavyComponent />
    </Suspense>
  )
}

Minimize JavaScript

Remove unused dependencies:

npm ls --depth=0 # Check what's installed
npm remove unused-package

Check bundle size:

npm install -D @next/bundle-analyzer
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
})

module.exports = withBundleAnalyzer({})

Run analysis:

ANALYZE=true npm run build

Server Components

Default to Server Components to reduce client JS:

// Server Component - no JS sent to browser
export default async function Page() {
  const data = await fetch('/api/data').then(r => r.json())
  return <div>{data}</div>
}

Caching Strategy

Implement multi-layer caching:

// app/blog/[slug]/page.tsx
export const revalidate = 3600 // Cache 1 hour

export async function generateStaticParams() {
  const posts = await fetch('/api/posts').then(r => r.json())
  return posts.map(p => ({ slug: p.slug }))
}

export async function generateMetadata({ params }) {
  const post = await fetch(`/api/posts/${params.slug}`, {
    next: { revalidate: 60 }
  }).then(r => r.json())

  return { title: post.title }
}

export default async function Page({ params }) {
  const post = await fetch(`/api/posts/${params.slug}`, {
    next: { tags: [`post-${params.slug}`] }
  }).then(r => r.json())

  return <article>{post.content}</article>
}

Database Query Optimization

// Fetch only needed fields
const posts = await db.posts.findMany({
  select: { id: true, title: true, slug: true },
  where: { published: true },
  take: 10
})

// Use indexes
const post = await db.posts.findUnique({
  where: { slug: params.slug }
})

// Batch queries
const [posts, users] = await Promise.all([
  db.posts.findMany(),
  db.users.findMany()
])

Streaming

Stream content progressively:

// app/page.tsx
import { Suspense } from 'react'

export default async function Page() {
  return (
    <div>
      <Suspense fallback={<SkeletonHeader />}>
        <Header />
      </Suspense>

      <Suspense fallback={<SkeletonContent />}>
        <MainContent />
      </Suspense>

      <Suspense fallback={<SkeletonSidebar />}>
        <Sidebar />
      </Suspense>
    </div>
  )
}

Users see skeleton immediately while content loads independently.

Reduce CSS

/* app/globals.css */
/* Remove unused styles */
/* Use Tailwind purge if not using Tailwind */

Network Optimization

// next.config.js
export default {
  compress: true, // Gzip compression
  poweredByHeader: false, // Remove header
  headers: async () => [
    {
      source: '/(.*)',
      headers: [
        {
          key: 'Cache-Control',
          value: 'public, max-age=31536000, immutable'
        }
      ]
    }
  ]
}

Real-World: Optimized Blog

// app/blog/page.tsx
import { Suspense } from 'react'
import Image from 'next/image'
import Link from 'next/link'

export const revalidate = 3600

async function getPosts() {
  return fetch('/api/posts?take=10', {
    next: { revalidate: 60, tags: ['posts'] }
  }).then(r => r.json())
}

function PostSkeleton() {
  return (
    <div className="space-y-4">
      {[1, 2, 3].map(i => (
        <div key={i} className="h-32 bg-gray-200 rounded animate-pulse" />
      ))}
    </div>
  )
}

async function PostList() {
  const posts = await getPosts()

  return (
    <ul className="space-y-6">
      {posts.map(post => (
        <li key={post.id}>
          <Link href={`/blog/${post.slug}`} className="flex gap-4">
            <Image
              src={post.image}
              alt={post.title}
              width={200}
              height={150}
              className="rounded"
            />
            <div>
              <h2 className="text-xl font-bold">{post.title}</h2>
              <p>{post.excerpt}</p>
            </div>
          </Link>
        </li>
      ))}
    </ul>
  )
}

export default function BlogPage() {
  return (
    <div>
      <h1>Blog</h1>
      <Suspense fallback={<PostSkeleton />}>
        <PostList />
      </Suspense>
    </div>
  )
}

Performance Monitoring

// lib/metrics.ts
export function reportWebVitals(metric) {
  if (process.env.NEXT_PUBLIC_ANALYTICS_ID) {
    fetch('/api/analytics', {
      method: 'POST',
      body: JSON.stringify(metric)
    })
  }
}
// pages/_app.tsx
import { reportWebVitals } from '@/lib/metrics'

export default function App({ Component, pageProps }) {
  return <Component {...pageProps} />
}

export { reportWebVitals }

FAQ

Q: What's the impact of slow Core Web Vitals? A: Google ranks slower sites lower. Users also leave slow sites, reducing engagement.

Q: Should I prioritize LCP, FID, or CLS? A: All are important, but start with LCP as it affects perceived performance most.

Q: How often should I monitor performance? A: Continuously. Set up alerts for metric regressions.


Performance optimization is an ongoing process that directly impacts user satisfaction and revenue.

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro