Next.js 15 App Router Performance Optimization
Expert knowledge for optimizing Next.js 15 App Router applications, based on real-world optimizations achieving FCP -55%, SI -61%.
Next.js 15 Key Changes:
- •
fetch()is NOT cached by default (opt-in withcache: 'force-cache')- •Partial Prerendering (PPR) available as experimental feature
- •
use cachedirective for granular caching control- •Streaming metadata for improved perceived performance
- •React 19 features including
use()hook
When to Use This Skill
- •Debugging LCP (Largest Contentful Paint) issues in Next.js 15
- •Optimizing image loading and preloading
- •Restructuring Server/Client Component architecture
- •Implementing streaming-aware patterns
- •Configuring caching strategies (Next.js 15 no longer caches fetch by default)
- •Implementing Partial Prerendering (PPR)
- •Analyzing Core Web Vitals issues
Next.js 15 Caching Changes
⚠️ Breaking Change in Next.js 15:
fetch()requests are NOT cached by default. You must explicitly opt-in to caching.
Caching Strategies
// ❌ NOT CACHED (Next.js 15 default behavior)
const data = await fetch('https://api.example.com/data');
// ✅ CACHED - opt-in with force-cache
const data = await fetch('https://api.example.com/data', {
cache: 'force-cache'
});
// ✅ TIME-BASED REVALIDATION
const data = await fetch('https://api.example.com/data', {
next: { revalidate: 3600 } // Revalidate every hour
});
// ✅ TAG-BASED REVALIDATION
const data = await fetch('https://api.example.com/data', {
next: { tags: ['products'] }
});
// Then revalidate with: revalidateTag('products')
Using unstable_cache for Non-Fetch Operations
import { unstable_cache } from 'next/cache';
const getCachedUser = unstable_cache(
async (userId: string) => {
return db.query.users.findFirst({ where: eq(users.id, userId) });
},
['user-cache'], // cache key prefix
{
revalidate: 3600,
tags: ['users']
}
);
Partial Prerendering (PPR)
PPR allows combining static and dynamic content in the same route for optimal performance.
Enabling PPR
// next.config.ts
import type { NextConfig } from 'next';
const config: NextConfig = {
experimental: {
ppr: 'incremental', // Enable PPR incrementally per-route
},
};
export default config;
Using PPR in Routes
// app/dashboard/layout.tsx
export const experimental_ppr = true;
export default function Layout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
// app/dashboard/page.tsx
import { Suspense } from 'react';
export default function Page() {
return (
<>
{/* Static shell - prerendered at build time */}
<StaticHeader />
<StaticSidebar />
{/* Dynamic content - streamed at request time */}
<Suspense fallback={<DashboardSkeleton />}>
<DynamicDashboard />
</Suspense>
</>
);
}
What Makes Components Dynamic
A component becomes dynamic when it uses:
- •
cookies()orheaders() - •
connection()ordraftMode() - •
searchParamsprop - •
unstable_noStore() - •
fetch()with{ cache: 'no-store' }
Critical: LCP Image Preload in App Router
⚠️ The
priorityprop on<Image>only works in Server Components. In Client Components (or children of Client Components), the preload link ends up in<body>instead of<head>, making it useless for LCP.
Why This Happens
Next.js App Router uses streaming. When <Image priority> is in a Client Component:
- •The shell (
<head>) is sent first - •Client Components stream later
- •The preload from
priorityarrives in<body>- too late for LCP
The Correct Pattern
// layout.tsx - PRELOAD HERE (renders before children stream)
import { getImageProps } from "next/image";
import { preload } from "react-dom";
export default async function Layout({ children, params }) {
const data = await getData(params);
if (data.lcpImage) {
const imageProps = getImageProps({
src: data.lcpImage,
width: 1200,
height: 600,
alt: ""
});
preload(imageProps.props.src, {
as: "image",
fetchPriority: "high",
imageSrcSet: imageProps.props.srcSet,
imageSizes: imageProps.props.sizes,
});
}
return <>{children}</>;
}
// In the component - NO priority prop, use loading="eager" + fetchPriority
<Image
src={url}
loading="eager"
fetchPriority="high"
width={1200}
height={600}
alt="Hero image"
/>
Server/Client Component Architecture
The Problem
// ❌ WRONG: Parent "use client" makes ALL children Client Components
"use client";
export function Header() {
return <Gallery />; // Gallery loses Server Component benefits!
}
When a parent has "use client", ALL children become Client Components, even if they don't have the directive. This breaks LCP preloading and increases bundle size.
The Solution: Push Client Boundaries to Leaves
// ✅ CORRECT: Server parent, isolated Client leaves
// Header.tsx (Server Component - no "use client")
export function Header() {
return (
<>
<Gallery /> {/* Server Component - LCP works */}
<LeadModal /> {/* Has its own "use client" */}
</>
);
}
// LeadModal.tsx
"use client";
export function LeadModal() {
// Interactive code here
}
Reducing JS Bundle Size (Next.js 15 Best Practice)
Add 'use client' to specific interactive components instead of marking large parts of UI as Client Components:
// ✅ Layout is Server Component, only Search needs client
import Search from './search'; // Client Component
import Logo from './logo'; // Server Component
export default function Layout({ children }) {
return (
<>
<nav>
<Logo /> {/* Zero JS sent to client */}
<Search /> {/* Only this sends JS */}
</nav>
{children}
</>
);
}
Key Principles
- •Push
"use client"to leaves - Only add it to components that truly need interactivity - •Keep LCP elements in Server Components - Images, hero sections, main content
- •Pass Server data as props - Don't fetch in Client Components what you can fetch on server
- •Render providers as deep as possible - Wrap
{children}not entire<html> - •Use
server-onlypackage - Prevent accidental client imports of server code
Context Providers Pattern
// app/providers.tsx
"use client";
import { ThemeProvider } from './theme-provider';
export function Providers({ children }) {
return <ThemeProvider>{children}</ThemeProvider>;
}
// app/layout.tsx (Server Component)
import { Providers } from './providers';
export default function RootLayout({ children }) {
return (
<html>
<body>
<Providers>{children}</Providers> {/* Wrap only children */}
</body>
</html>
);
}
Good to know: Render providers as deep as possible in the tree. Notice how
ThemeProvideronly wraps{children}instead of the entire<html>document. This makes it easier for Next.js to optimize the static parts of your Server Components.
Streaming Metadata (Next.js 15)
Next.js 15 streams metadata separately, allowing visual content to render before metadata resolves:
// app/blog/[slug]/page.tsx
export async function generateMetadata({ params }) {
// This doesn't block UI rendering in Next.js 15
const post = await fetchPost(params.slug);
return {
title: post.title,
description: post.excerpt,
};
}
Note: Streaming metadata is disabled for bots/crawlers (Twitterbot, Slackbot, Bingbot) that expect metadata in
<head>. Customize withhtmlLimitedBotsconfig.
Server Actions Configuration
// next.config.js
module.exports = {
experimental: {
serverActions: {
bodySizeLimit: '2mb',
allowedOrigins: ['my-proxy.com', '*.my-proxy.com'],
},
},
};
Image Optimization (Next.js 15)
Local Images with Auto Dimensions
import Image from 'next/image';
import profilePic from './profile.png';
// Width/height automatically inferred from static import
<Image src={profilePic} alt="Profile" />
Remote Images with Required Config
// next.config.ts
const config = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 's3.amazonaws.com',
port: '',
pathname: '/my-bucket/**',
},
],
},
};
Route Handlers Caching
// app/api/data/route.ts
// ❌ NOT CACHED by default
export async function GET() {
const data = await fetch('https://...');
return Response.json(data);
}
// ✅ CACHED with config
export const dynamic = 'force-static';
export async function GET() {
const data = await fetch('https://...');
return Response.json(data);
}
Middleware Best Practices
Middleware is effective for:
- •Quick redirects after reading request
- •Rewriting based on A/B tests
- •Modifying headers
NOT good for:
- •Slow data fetching
- •Session management
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
return NextResponse.redirect(new URL('/home', request.url));
}
export const config = {
matcher: '/about/:path*',
};
Verification Commands
Check if preload is in <head>
curl -s "https://your-site.com" \ -H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" \ -H "Accept: text/html" \ | tr '>' '\n' | grep -n 'as="image"\|</head'
The preload line number should be LESS than the </head> line number.
Check for fetchpriority="high" on LCP image
curl -s "https://your-site.com" \ -H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" \ -H "Accept: text/html" \ | grep -i 'fetchpriority="high"'
Common Mistakes to Avoid
- •Assuming fetch is cached - In Next.js 15, it's NOT cached by default
- •Using
priorityin Client Components - It won't work as expected - •Wrapping LCP images in Client Component parents - Breaks preloading
- •Not using
getImageProps()for manual preloads - Loses srcset/sizes optimization - •Forgetting
fetchPriority="high"on the actual image - Preload alone isn't enough - •Using
loading="lazy"on LCP images - Defeats the purpose - •Not wrapping dynamic content in Suspense - Prevents PPR benefits
- •Wrapping entire
<html>in providers - Prevents static optimization
Debugging Checklist
- • Is the LCP element in a Server Component?
- • Is
ReactDOM.preload()called inlayout.tsx? - • Does the preload appear in
<head>(not<body>)? - • Does the image have
fetchPriority="high"? - • Is
loading="eager"set (not lazy)? - • Are parent components Server Components?
- • Is caching explicitly configured for
fetch()calls? - • Is dynamic content wrapped in
<Suspense>? - • Are context providers rendered deep, not wrapping
<html>?