Optimizing Performance in Next.js 15
Performance is not a feature you add at the end. It is a discipline you practice from the first commit. Next.js 15 gives you more tools than ever to build fast by default — but you still need to know how to use them.
This post covers the most impactful optimizations available in Next.js 15, with real code you can apply today.
"Fast is a feature. Users notice when your site is slow, and they leave. They rarely notice when it's fast — they just stay." — Jeff Atwood
1. Partial Prerendering (PPR): The Game Changer
Partial Prerendering is the most significant architectural shift in Next.js 15. It lets you combine static and dynamic rendering on the same page — something that was previously impossible without complex workarounds.
How PPR Works
Traditional SSG: Entire page is static → Fast, but no personalization
Traditional SSR: Entire page is dynamic → Personalized, but slow TTFB
PPR: Static shell + dynamic islands → Fast AND personalized
// next.config.mjs — enable PPR
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
ppr: true,
},
};
export default nextConfig;
// app/dashboard/page.tsx
import { Suspense } from 'react';
import { StaticHero } from './StaticHero'; // Prerendered at build time
import { DynamicFeed } from './DynamicFeed'; // Streamed at request time
import { FeedSkeleton } from './FeedSkeleton'; // Shown instantly while streaming
export default function DashboardPage() {
return (
<main>
{/* This renders at build time — zero latency */}
<StaticHero />
{/* This streams in — user sees skeleton immediately */}
<Suspense fallback={<FeedSkeleton />}>
<DynamicFeed />
</Suspense>
</main>
);
}
2. The New Caching Model
Next.js 15 overhauled caching to be more explicit and predictable. The old "cache everything by default" behavior is gone.
Fetch Caching
// No cache — always fresh data (like SSR)
const data = await fetch('/api/live-prices', {
cache: 'no-store',
});
// Cache indefinitely — revalidate manually (like SSG)
const data = await fetch('/api/static-content', {
cache: 'force-cache',
});
// Time-based revalidation — best of both worlds
const data = await fetch('/api/blog-posts', {
next: { revalidate: 3600 }, // Revalidate every hour
});
// Tag-based revalidation — revalidate on demand
const data = await fetch('/api/products', {
next: { tags: ['products'] },
});
// Trigger revalidation from a Server Action
'use server';
import { revalidateTag, revalidatePath } from 'next/cache';
export async function updateProduct(id: string, data: ProductData) {
await db.products.update({ where: { id }, data });
// Invalidate all fetches tagged 'products'
revalidateTag('products');
// Or invalidate a specific page
revalidatePath('/products');
}
revalidate: 0 or cache: 'no-store' for real-time data. revalidate: N for data that changes occasionally. force-cache with revalidateTag for data you control explicitly.3. React Server Components: Eliminating Client JavaScript
Every component that does not need interactivity should be a Server Component. This is the single highest-impact optimization in the React/Next.js ecosystem.
// BAD: Fetching data in a Client Component
'use client';
import { useState, useEffect } from 'react';
export function ProductList() {
const [products, setProducts] = useState([]);
useEffect(() => {
fetch('/api/products').then(r => r.json()).then(setProducts);
}, []);
// Problems:
// 1. Extra network round-trip for the API call
// 2. JavaScript bundle includes this component
// 3. Loading state needed (flash of empty content)
return <div>{products.map(p => <ProductCard key={p.id} product={p} />)}</div>;
}
// GOOD: Fetching data in a Server Component
// No 'use client' directive — this runs on the server
import { db } from '@/lib/database';
export async function ProductList() {
// Direct database access — no API round-trip
const products = await db.products.findMany({
orderBy: { createdAt: 'desc' },
take: 20,
});
// Benefits:
// 1. Zero JavaScript sent to client for this component
// 2. No loading state needed — data is ready when HTML renders
// 3. Direct DB access — faster than going through an API
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{products.map(p => <ProductCard key={p.id} product={p} />)}
</div>
);
}
The Component Decision Tree
Does this component need:
├── onClick, onChange, or other event handlers? → Client Component
├── useState or useReducer? → Client Component
├── useEffect? → Client Component
├── Browser-only APIs (window, localStorage)? → Client Component
└── None of the above? → Server Component ✓
4. Image Optimization
next/image component is one of the most impactful optimizations you can make with zero effort.import Image from 'next/image';
// Automatic: WebP/AVIF conversion, lazy loading, size optimization
<Image
src="/hero.jpg"
alt="Hero image"
width={1200}
height={630}
priority // Load eagerly for above-the-fold images
quality={85} // Default is 75; 85 is a good balance
placeholder="blur" // Show blurred placeholder while loading
blurDataURL="data:image/jpeg;base64,..." // Generate with plaiceholder
/>
// For images with unknown dimensions (fill parent)
<div className="relative aspect-video">
<Image
src={imageUrl}
alt="Dynamic image"
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
className="object-cover"
/>
</div>
sizes prop for fill images. Without it, Next.js downloads a full-resolution image even on mobile. The sizes prop tells the browser which image size to request based on viewport width.5. Bundle Analysis and Code Splitting
# Analyze your bundle
npm install @next/bundle-analyzer
// next.config.mjs
import bundleAnalyzer from '@next/bundle-analyzer';
const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
});
export default withBundleAnalyzer({
// your config
});
ANALYZE=true npm run build
# Opens an interactive treemap of your bundle
Dynamic Imports for Heavy Components
import dynamic from 'next/dynamic';
// Only load this when it's actually needed
const HeavyChart = dynamic(() => import('@/components/HeavyChart'), {
loading: () => <div className="animate-pulse h-64 rounded-2xl bg-white/5" />,
ssr: false, // Don't render on server (for browser-only libraries)
});
// Only load the map when user scrolls to it
const InteractiveMap = dynamic(() => import('@/components/Map'), {
ssr: false,
});
6. Core Web Vitals: What to Measure
| Metric | What it measures | Good | Needs Work | Poor |
|---|---|---|---|---|
| LCP | Largest Contentful Paint (load speed) | < 2.5s | 2.5-4s | > 4s |
| INP | Interaction to Next Paint (responsiveness) | < 200ms | 200-500ms | > 500ms |
| CLS | Cumulative Layout Shift (visual stability) | < 0.1 | 0.1-0.25 | > 0.25 |
// Measure and report Web Vitals
// app/layout.tsx
export function reportWebVitals(metric: any) {
// Send to your analytics
if (metric.label === 'web-vital') {
console.log(metric); // { id, name, startTime, value, label }
// Example: send to Vercel Analytics
// analytics.track('Web Vital', metric);
}
}
priority, render-blocking fonts, and slow server response times. Fix these first before optimizing anything else.7. Font Optimization
// app/layout.tsx
import { Inter, JetBrains_Mono } from 'next/font/google';
// next/font automatically:
// - Self-hosts fonts (no Google DNS lookup)
// - Generates optimal font-display: swap
// - Eliminates layout shift with size-adjust
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
display: 'swap',
});
const mono = JetBrains_Mono({
subsets: ['latin'],
variable: '--font-mono',
display: 'swap',
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${inter.variable} ${mono.variable}`}>
<body className="font-sans">{children}</body>
</html>
);
}
8. Performance Checklist
Before shipping any Next.js 15 app, run through this:
next/image with correct sizes prop. Above-fold images have priority.next/font/google or next/font/local. No external font CDN requests.'use client' directives.fetch call has an explicit cache strategy. No accidental over-fetching.ANALYZE=true npm run build and investigate anything over 100KB.Watch: Next.js Performance Deep Dive

The Bottom Line
next/image, and next/font give you massive wins with minimal effort. The developers who ship the fastest apps are not doing anything exotic — they are just using the defaults properly.Measure first. Optimize what the data tells you to optimize. Ship.

Vighnesh Salunkhe
"Passionate about building scalable web applications and exploring the intersection of AI and human creativity."
Join the Conversation
Share your thoughts or ask a question