Optimizing Performance in Next.js 15
Performance8 min read

Optimizing Performance in Next.js 15

Vighnesh Salunkhe

Vighnesh Salunkhe

Full Stack Developer

Published

April 25, 2026

Optimizing Performance in Next.js 15

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

text
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
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>
  );
}
With PPR, your Time to First Byte (TTFB) is near-instant because the static shell is served from the CDN edge. The dynamic parts stream in progressively. Users see content immediately instead of staring at a blank screen.

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');
}
The key mental model:
text
revalidate: 0
or
text
cache: 'no-store'
for real-time data.
text
revalidate: N
for data that changes occasionally.
text
force-cache
with
text
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.

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

text
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

The

text
next/image
component is one of the most impactful optimizations you can make with zero effort.

text
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>
Always provide the
text
sizes
prop for
text
fill
images. Without it, Next.js downloads a full-resolution image even on mobile. The
text
sizes
prop tells the browser which image size to request based on viewport width.

5. 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
});
text
ANALYZE=true npm run build
# Opens an interactive treemap of your bundle

Dynamic Imports for Heavy Components

text
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

MetricWhat it measuresGoodNeeds WorkPoor
LCPLargest Contentful Paint (load speed)< 2.5s2.5-4s> 4s
INPInteraction to Next Paint (responsiveness)< 200ms200-500ms> 500ms
CLSCumulative Layout Shift (visual stability)< 0.10.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);
  }
}
LCP is almost always your biggest win. The most common culprits: large hero images without
text
priority
, render-blocking fonts, and slow server response times. Fix these first before optimizing anything else.

7. 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:

Images — All images use
text
next/image
with correct
text
sizes
prop. Above-fold images have
text
priority
.
Fonts — Using
text
next/font/google
or
text
next/font/local
. No external font CDN requests.
Server Components — All non-interactive components are Server Components. No unnecessary
text
'use client'
directives.
Dynamic imports — Heavy third-party libraries (charts, maps, editors) are dynamically imported.
Caching — Each
text
fetch
call has an explicit cache strategy. No accidental over-fetching.
Bundle size — Run
text
ANALYZE=true npm run build
and investigate anything over 100KB.

Watch: Next.js Performance Deep Dive

Video thumbnail
Watch on YouTube

The Bottom Line

Performance optimization in Next.js 15 is mostly about using the framework correctly. Server Components, PPR,

text
next/image
, and
text
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.

#Next.js#Web Perf#React#Frontend
Vighnesh Salunkhe
Written by

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

Share this article