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
textTraditional 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
text// next.config.mjs — enable PPR /** @type {import('next').NextConfig} */ const nextConfig = { experimental: { ppr: true, }, }; export default nextConfig;
text// 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
text// 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'] }, });
text// 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: 0cache: 'no-store'revalidate: Nforce-cacherevalidateTag3. 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.
text// 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>; }
text// 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
textDoes 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
The
next/imagetextimport 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>
sizesfillsizes5. Bundle Analysis and Code Splitting
text# Analyze your bundle npm install @next/bundle-analyzer
text// next.config.mjs import bundleAnalyzer from '@next/bundle-analyzer'; const withBundleAnalyzer = bundleAnalyzer({ enabled: process.env.ANALYZE === 'true', }); export default withBundleAnalyzer({ // your config });
textANALYZE=true npm run build # Opens an interactive treemap of your bundle
Dynamic Imports for Heavy Components
textimport 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 |
text// 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); } }
priority7. Font Optimization
text// 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/imagesizesprioritynext/font/googlenext/font/local'use client'fetchANALYZE=true npm run buildWatch: Next.js Performance Deep Dive

The Bottom Line
Performance optimization in Next.js 15 is mostly about using the framework correctly. Server Components, PPR,
next/imagenext/fontMeasure 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