[ ]Relogic
Next.js 15 Performance Deep Dive
Next.jsPerformanceReactVercel

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:

  1. Static (SSG) — rendered at build time, served from CDN
  2. Partial Pre-rendering (PPR) — static shell with dynamic holes, served from edge
  3. Server Components — zero-JS server rendering with Suspense streaming
  4. 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 entries

Image 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 build

Common culprits I find in almost every project:

  • moment.js — replace with date-fns or native Intl
  • Large icon libraries imported wholesale — use lucide-react with tree-shaking
  • lodash — either use lodash-es or 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:

MetricGoodNeeds WorkPoor
LCP< 2.5s2.5–4.0s> 4.0s
INP< 200ms200–500ms> 500ms
CLS< 0.10.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.

R

Relogic Studio

Web development agency specialising in motion-driven digital experiences.