Next.js 15 Performance Deep Dive
App Router, Partial Pre-rendering, streaming, and edge caching — a practical guide to squeezing every millisecond out of Next.js 15.
Next.js 15 ships with performance improvements that fundamentally change how you should think about rendering strategies. Here's what actually matters and how to leverage it.
The New Rendering Mental Model
Forget the old Static vs SSR binary. Next.js 15 gives you a spectrum:
- Static (SSG) — rendered at build time, served from CDN
- Partial Pre-rendering (PPR) — static shell with dynamic holes, served from edge
- Server Components — zero-JS server rendering with Suspense streaming
- Client Components — full React hydration where needed
The key insight: most of your page is static. PPR lets you ship the static parts instantly while streaming in the dynamic bits.
Partial Pre-rendering in Practice
// next.config.ts
const nextConfig: NextConfig = {
experimental: {
ppr: true,
},
};
// app/dashboard/page.tsx
import { Suspense } from "react";
import { DashboardStats } from "./DashboardStats"; // dynamic
import { StaticSidebar } from "./StaticSidebar"; // static
export default function Dashboard() {
return (
<div className="grid grid-cols-4 gap-6">
{/* Shipped instantly from edge cache */}
<StaticSidebar />
{/* Streamed in as soon as data resolves */}
<Suspense fallback={<StatsSkeleton />}>
<DashboardStats />
</Suspense>
</div>
);
}The result? Your First Contentful Paint hits instantly from the edge cache, while dynamic data streams in progressively. Users see content far earlier than with traditional SSR.
Server Components: What Goes Where
The rule of thumb:
// Server Component — data fetching, no interactivity
// app/blog/page.tsx
async function BlogPage() {
const posts = await db.posts.findMany(); // Direct DB access, no API round-trip
return <PostList posts={posts} />;
}
// Client Component — interactivity, browser APIs, hooks
// components/LikeButton.tsx
"use client";
function LikeButton({ postId }: { postId: string }) {
const [liked, setLiked] = useState(false);
return <button onClick={() => setLiked(!liked)}>{liked ? "❤️" : "🤍"}</button>;
}The mistake everyone makes: adding "use client" to a component that contains a data-fetching child. This forces the entire subtree to render client-side. Instead, pass server-fetched data down as props or use composition.
Fetch Deduplication and Caching
Next.js 15 extends fetch with caching primitives:
// Cached for 1 hour, shared across all requests
const data = await fetch("https://api.example.com/products", {
next: { revalidate: 3600 },
});
// Never cache — always fresh
const dynamic = await fetch("https://api.example.com/inventory", {
cache: "no-store",
});
// Tagged cache — revalidate by tag
const tagged = await fetch("https://api.example.com/products", {
next: { tags: ["products"] },
});
// Later, on a product update:
import { revalidateTag } from "next/cache";
revalidateTag("products"); // Purges all "products" tagged cache entriesImage Optimisation
next/image is criminally underused. Beyond automatic WebP conversion, make sure you're using:
// Priority loading for above-fold images
<Image src={hero} alt="Hero" priority />
// Proper sizes for responsive images (prevents massive downloads on mobile)
<Image
src={product}
alt={name}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
fill
/>
// Blur placeholder eliminates layout shift
<Image
src={product}
placeholder="blur"
blurDataURL={tinyBase64DataURL}
/>Bundle Analysis
Run this regularly:
# Install analyzer
pnpm add -D @next/bundle-analyzer
# next.config.ts
import bundleAnalyzer from "@next/bundle-analyzer";
const withBundleAnalyzer = bundleAnalyzer({ enabled: process.env.ANALYZE === "true" });
export default withBundleAnalyzer(nextConfig);
# Run analysis
ANALYZE=true pnpm buildCommon culprits I find in almost every project:
moment.js— replace withdate-fnsor nativeIntl- Large icon libraries imported wholesale — use
lucide-reactwith tree-shaking lodash— either uselodash-esor replace with native JS- Entire chart libraries — use dynamic imports with
next/dynamic
Dynamic Imports for Heavy Components
import dynamic from "next/dynamic";
// Chart libraries, rich text editors, etc.
const RichEditor = dynamic(() => import("./RichEditor"), {
loading: () => <EditorSkeleton />,
ssr: false, // Skip server render if it uses browser APIs
});
// Only load when user actually opens the modal
const HeavyModal = dynamic(() => import("./HeavyModal"));Measuring What Matters
Core Web Vitals targets for 2024:
| Metric | Good | Needs Work | Poor |
|---|---|---|---|
| LCP | < 2.5s | 2.5–4.0s | > 4.0s |
| INP | < 200ms | 200–500ms | > 500ms |
| CLS | < 0.1 | 0.1–0.25 | > 0.25 |
INP (Interaction to Next Paint) replaced FID in 2024 and is harder to optimise. The key: keep your event handlers lean, move heavy work off the main thread with scheduler.postTask or Web Workers.
Performance is a feature, and it compounds. Faster pages rank higher, convert better, and retain longer. Build the habit of measuring before optimising, and always ship what you can measure.
Relogic Studio
Web development agency specialising in motion-driven digital experiences.