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 hooksData 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 revalidateUsing 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 buildDynamic 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 viewportFID / 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 weightsWrapping 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.