FRONTEND2025-08-05📖 3 min read

Next.js 15 Performance Optimization Guide: Practical Techniques for Improving Core Web Vitals

Next.js 15 Performance Optimization Guide: Practical Techniques for Improving Core Web Vitals

A deep dive into performance optimization best practices using Next.js 15 features. From Server Components and caching strategies to image optimization and Core Web Vitals improvements — everything you need to ship a faster app.

髙木 晃宏

代表 / エンジニア

👨‍💼

TL;DR

  • Server Components can cut your client bundle by 50% or more
  • The right caching strategy keeps TTFB under 300ms
  • next/image and next/font automate image and font optimization
  • Streaming SSR significantly improves FCP

Why Performance Matters

According to Google research, when page load time increases from 1 second to 3 seconds, bounce rate jumps by 32%. At 5 seconds, it reaches 90%.

On top of that, Core Web Vitals have been a Google Search ranking factor since 2021. Performance is no longer a nice-to-have — it's a requirement.

After migrating to Next.js 15 and applying the optimizations in this guide, our team achieved the following improvements:

  • LCP: 4.2s → 1.8s (57% improvement)
  • FID: 180ms → 45ms (75% improvement)
  • CLS: 0.25 → 0.05 (80% improvement)
  • Bounce rate: 45% → 28% (38% improvement)

This article walks through the specific techniques that made these results possible.

What's New in Next.js 15 for Performance

Turbopack

Turbopack reached stable status in Next.js 15, delivering major improvements to development build speed.

{ "scripts": { "dev": "next dev --turbopack", "build": "next build", "start": "next start" } }

Benefits of Turbopack:

  • Initial compile: up to 76% faster
  • HMR (Hot Module Replacement): up to 96% faster
  • Memory usage: 30% reduction

Partial Prerendering (PPR)

The headline feature of Next.js 15. PPR lets you optimally combine static and dynamic content within a single page.

// app/products/[id]/page.tsx import { Suspense } from 'react'; import { ProductInfo } from './ProductInfo'; import { ProductReviews } from './ProductReviews'; import { RecommendedProducts } from './RecommendedProducts'; export default function ProductPage({ params }: { params: { id: string } }) { return ( <div> {/* Statically prerendered */} <ProductInfo productId={params.id} /> {/* Dynamic content delivered via streaming */} <Suspense fallback={<ReviewsSkeleton />}> <ProductReviews productId={params.id} /> </Suspense> <Suspense fallback={<RecommendedSkeleton />}> <RecommendedProducts productId={params.id} /> </Suspense> </div> ); }
// next.config.js module.exports = { experimental: { ppr: true } };

Optimizing Server Components

Minimize Client Components

Server Components are the default. Use 'use client' only when you genuinely need it.

// ❌ Bad: marking the entire component as a Client Component 'use client'; import { useState } from 'react'; export default function ProductPage({ product }) { const [quantity, setQuantity] = useState(1); return ( <div> <h1>{product.name}</h1> <p>{product.description}</p> <img src={product.image} alt={product.name} /> {/* Only this part actually needs interactivity */} <div> <button onClick={() => setQuantity(q => q - 1)}>-</button> <span>{quantity}</span> <button onClick={() => setQuantity(q => q + 1)}>+</button> </div> </div> ); }
// ✅ Good: isolate only the interactive part as a Client Component // app/products/[id]/page.tsx (Server Component) import { getProduct } from '@/lib/products'; import { QuantitySelector } from './QuantitySelector'; export default async function ProductPage({ params }: { params: { id: string } }) { const product = await getProduct(params.id); return ( <div> <h1>{product.name}</h1> <p>{product.description}</p> <img src={product.image} alt={product.name} /> {/* Keep the Client Component surface as small as possible */} <QuantitySelector productId={product.id} /> </div> ); } // app/products/[id]/QuantitySelector.tsx (Client Component) 'use client'; import { useState } from 'react'; export function QuantitySelector({ productId }: { productId: string }) { const [quantity, setQuantity] = useState(1); return ( <div> <button onClick={() => setQuantity(q => Math.max(1, q - 1))}>-</button> <span>{quantity}</span> <button onClick={() => setQuantity(q => q + 1)}>+</button> </div> ); }

Component Splitting Guidelines

Good candidates for Server Components: ✅ Data fetching ✅ Accessing backend resources ✅ Handling sensitive data (API keys, etc.) ✅ Using large dependencies ✅ Static UI Good candidates for Client Components: ✅ Event handlers (onClick, onChange, etc.) ✅ Hooks like useState and useEffect ✅ Browser APIs (localStorage, geolocation, etc.) ✅ Custom hooks

Data Fetching and Caching Strategies

Extended fetch Options

In Next.js 15, the fetch API is extended to give you fine-grained cache control.

// Examples of caching strategies // 1. Static data (fetched at build time, never revalidated) const staticData = await fetch('https://api.example.com/static', { cache: 'force-cache' // default }); // 2. Dynamic data (fetched on every request) const dynamicData = await fetch('https://api.example.com/dynamic', { cache: 'no-store' }); // 3. Time-based revalidation (ISR) const revalidatedData = await fetch('https://api.example.com/products', { next: { revalidate: 3600 } // revalidate every hour }); // 4. Tag-based revalidation const taggedData = await fetch('https://api.example.com/products', { next: { tags: ['products'] } }); // call revalidateTag('products') to manually revalidate

Using unstable_cache

Cache non-fetch data sources like database queries.

import { unstable_cache } from 'next/cache'; import { db } from '@/lib/db'; const getCachedUser = unstable_cache( async (userId: string) => { return db.user.findUnique({ where: { id: userId }, include: { posts: true } }); }, ['user'], // cache key { revalidate: 3600, // 1 hour tags: ['user'] // tag for manual revalidation } ); export default async function UserProfile({ params }: { params: { id: string } }) { const user = await getCachedUser(params.id); // ... }

Parallel Data Fetching

// ❌ Bad: sequential fetches (slow) export default async function Dashboard() { const user = await getUser(); // 500ms const posts = await getPosts(); // 500ms const comments = await getComments(); // 500ms // total: 1500ms } // ✅ Good: parallel fetches (fast) export default async function Dashboard() { const [user, posts, comments] = await Promise.all([ getUser(), // 500ms getPosts(), // 500ms getComments() // 500ms ]); // total: 500ms (bounded by the slowest request) }

Image Optimization

Getting the Most Out of next/image

import Image from 'next/image'; // Basic usage export function HeroImage() { return ( <Image src="/hero.jpg" alt="Hero image" width={1920} height={1080} priority // required for LCP images quality={85} placeholder="blur" blurDataURL="data:image/jpeg;base64,..." /> ); } // Responsive image export function ResponsiveImage() { return ( <Image src="/product.jpg" alt="Product" fill sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" style={{ objectFit: 'cover' }} /> ); } // Optimizing external images // next.config.js module.exports = { images: { remotePatterns: [ { protocol: 'https', hostname: 'images.example.com', pathname: '/uploads/**' } ], formats: ['image/avif', 'image/webp'], deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048], imageSizes: [16, 32, 48, 64, 96, 128, 256, 384] } };

Lazy Loading Strategy

// Lazy-load anything below the fold export function ProductGallery({ images }: { images: string[] }) { return ( <div className="grid grid-cols-3 gap-4"> {images.map((src, index) => ( <Image key={src} src={src} alt={`Product image ${index + 1}`} width={400} height={400} // eager-load the first image, lazy-load the rest loading={index === 0 ? 'eager' : 'lazy'} priority={index === 0} /> ))} </div> ); }

Font Optimization

Using next/font

// app/layout.tsx import { Inter, Noto_Sans_JP } from 'next/font/google'; // Use variable fonts to reduce file size const inter = Inter({ subsets: ['latin'], display: 'swap', variable: '--font-inter' }); // Japanese font — load only the weights you need const notoSansJP = Noto_Sans_JP({ subsets: ['latin'], weight: ['400', '700'], // only specify what you use display: 'swap', variable: '--font-noto-sans-jp', preload: true }); export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="ja" className={`${inter.variable} ${notoSansJP.variable}`}> <body>{children}</body> </html> ); }
/* globals.css */ body { font-family: var(--font-noto-sans-jp), var(--font-inter), sans-serif; }

Local Fonts

import localFont from 'next/font/local'; const myFont = localFont({ src: [ { path: './fonts/MyFont-Regular.woff2', weight: '400', style: 'normal' }, { path: './fonts/MyFont-Bold.woff2', weight: '700', style: 'normal' } ], display: 'swap', preload: true });

Reducing Bundle Size

Analyzing with bundle-analyzer

npm install @next/bundle-analyzer
// next.config.js const withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.ANALYZE === 'true' }); module.exports = withBundleAnalyzer({ // other config });
ANALYZE=true npm run build

Dynamic Imports

import dynamic from 'next/dynamic'; // Dynamically import heavy components const HeavyChart = dynamic(() => import('@/components/HeavyChart'), { loading: () => <ChartSkeleton />, ssr: false // client-side only }); // Conditional import const AdminPanel = dynamic(() => import('@/components/AdminPanel'), { loading: () => <AdminSkeleton /> }); export default function Dashboard({ isAdmin }: { isAdmin: boolean }) { return ( <div> <HeavyChart data={chartData} /> {isAdmin && <AdminPanel />} </div> ); }

Tree Shaking

// ❌ Bad: importing the entire module import { format, parse, addDays, subDays, differenceInDays } from 'date-fns'; // ✅ Good: import only what you need import format from 'date-fns/format'; import addDays from 'date-fns/addDays'; // ❌ Bad: importing all of lodash import _ from 'lodash'; _.map(items, fn); // ✅ Good: import only the function you need import map from 'lodash/map'; map(items, fn);

sideEffects in package.json

{ "sideEffects": [ "*.css", "*.scss" ] }

Streaming SSR and Suspense

How Streaming Works

Traditional SSR: Server: [fetch data → generate HTML] ───────────────────────▶ [send complete HTML] Browser: [receive] → [render] Streaming SSR: Server: [send shell] → [fetch + stream incrementally] ───────▶ Browser: [render shell] → [update incrementally] ────────────▶

Streaming in Practice

// app/dashboard/page.tsx import { Suspense } from 'react'; import { DashboardSkeleton } from './DashboardSkeleton'; export default function DashboardPage() { return ( <div className="dashboard"> {/* Rendered immediately (static shell) */} <header> <h1>Dashboard</h1> <nav>{/* navigation */}</nav> </header> {/* High priority: show first */} <Suspense fallback={<StatsSkeleton />}> <DashboardStats /> </Suspense> {/* Medium priority */} <Suspense fallback={<ChartSkeleton />}> <RevenueChart /> </Suspense> {/* Low priority: fine to show last */} <Suspense fallback={<TableSkeleton />}> <RecentTransactions /> </Suspense> </div> ); } // Each component is an async Server Component async function DashboardStats() { const stats = await fetchStats(); // independent data fetch return <StatsDisplay data={stats} />; } async function RevenueChart() { const data = await fetchRevenueData(); return <Chart data={data} />; } async function RecentTransactions() { const transactions = await fetchTransactions(); return <TransactionTable data={transactions} />; }

Using loading.tsx

// app/dashboard/loading.tsx export default function DashboardLoading() { return ( <div className="dashboard"> <header> <h1>Dashboard</h1> <nav>{/* navigation */}</nav> </header> <div className="animate-pulse"> <div className="h-32 bg-gray-200 rounded mb-4" /> <div className="h-64 bg-gray-200 rounded mb-4" /> <div className="h-96 bg-gray-200 rounded" /> </div> </div> ); }

Improving Core Web Vitals

LCP (Largest Contentful Paint)

// 1. Use the priority attribute to preload LCP images <Image src="/hero.jpg" priority /> // 2. Preload hints // app/layout.tsx export const metadata = { other: { 'link': [ { rel: 'preload', href: '/hero.jpg', as: 'image' } ] } }; // 3. Preload fonts const font = Inter({ preload: true, display: 'swap' }); // 4. Critical CSS inlining (automatic) // Next.js automatically inlines the CSS needed for the initial viewport

FID / INP (First Input Delay / Interaction to Next Paint)

// 1. Offload heavy work to a Web Worker // workers/heavyTask.ts self.onmessage = (e) => { const result = heavyComputation(e.data); self.postMessage(result); }; // 2. Use useTransition to control priority 'use client'; import { useTransition, useState } from 'react'; export function SearchResults() { const [query, setQuery] = useState(''); const [results, setResults] = useState([]); const [isPending, startTransition] = useTransition(); const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => { const value = e.target.value; setQuery(value); // high priority: reflect input immediately startTransition(() => { // low priority: search results can wait setResults(filterItems(value)); }); }; return ( <div> <input value={query} onChange={handleSearch} /> {isPending ? <Spinner /> : <ResultsList results={results} />} </div> ); } // 3. Use useDeferredValue to defer expensive rendering import { useDeferredValue } from 'react'; function SearchResults({ query }) { const deferredQuery = useDeferredValue(query); const results = useMemo(() => filterItems(deferredQuery), [deferredQuery]); return <ResultsList results={results} />; }

CLS (Cumulative Layout Shift)

// 1. Always specify image dimensions <Image src="/product.jpg" width={400} height={300} alt="Product" /> // 2. Reserve space for dynamic content export function AdBanner() { return ( <div style={{ minHeight: '250px' }}> {/* reserve minimum height */} <Suspense fallback={<AdSkeleton />}> <Ad /> </Suspense> </div> ); } // 3. Prevent FOUT with font fallback adjustments const font = Inter({ display: 'swap', adjustFontFallback: true // adjust fallback font metrics to match }); // 4. Match skeleton dimensions to actual content export function CardSkeleton() { return ( <div className="w-full h-[200px] bg-gray-200 rounded animate-pulse" /> ); }

Measuring Performance

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> ); }

Custom Metrics

// app/components/WebVitals.tsx 'use client'; import { useReportWebVitals } from 'next/web-vitals'; export function WebVitals() { useReportWebVitals((metric) => { // send to your analytics service console.log(metric); // send to Google Analytics window.gtag?.('event', metric.name, { value: Math.round(metric.name === 'CLS' ? metric.value * 1000 : metric.value), event_label: metric.id, non_interaction: true }); }); return null; }

Pre-Deploy Checklist

□ Lighthouse scores - Performance: 90+ - Accessibility: 90+ - Best Practices: 90+ - SEO: 90+ □ Core Web Vitals - LCP: under 2.5s - FID/INP: under 100ms - CLS: under 0.1 □ Bundle size - First Load JS: under 100KB (target) - No unnecessary packages □ Image optimization - Using next/image - Appropriate dimensions and formats - priority attribute on LCP images □ Font optimization - Using next/font - display: 'swap' configured - Only loading necessary weights

Wrapping Up

Performance optimization in Next.js 15 comes down to three pillars:

1. Server-Side Optimization

  • Use Server Components to shrink client bundles
  • Apply the right caching strategy to improve TTFB
  • Leverage Streaming SSR to improve FCP

2. Client-Side Optimization

  • Use Dynamic Imports to lighten the initial load
  • Use useTransition and useDeferredValue to improve INP
  • Optimize images and fonts

3. Continuous Measurement and Improvement

  • Monitor Core Web Vitals regularly
  • Track bundle size with bundle-analyzer
  • Collect Real User Metrics (RUM)

Performance optimization is never a one-time effort. The key is building a continuous cycle of measurement and improvement.

Resources


If you're struggling with Next.js performance, feel free to reach out.