Next.js 15: Deep Dive into the New Routing and Caching Patterns
An architectural exploration of Next.js 15 routing and caching updates. Learn about the shift to uncached-by-default GET requests, configuring staleTimes, and implementing Partial Prerendering (PPR).

Next.js 15 marks a pivotal turning point in the framework's history. While version 13 introduced the App Router and version 14 stabilized it, Next.js 15 focuses on correcting some of the developer friction that arose from aggressive caching defaults, introducing cutting-edge React 19 capabilities, and refining client-side page transitions.
In this deep dive, we will examine the architectural shifts in Next.js 15's routing and caching model, analyze the internals of Partial Prerendering (PPR), configure the new Client Router Cache, and look at how to build scalable, high-performance applications with the latest APIs.
1. The Paradigm Shift: Uncached by Default
In Next.js 13 and 14, the framework was designed to render statically and cache aggressively by default. While this yielded great performance for static sites, it created unexpected bugs in dynamic applications. Developers found themselves constantly writing export const dynamic = 'force-dynamic' or passing { cache: 'no-store' } to fetch calls to prevent outdated data from being served.
Next.js 15 reverses this behavior:
A. Fetch Requests
In Next.js 14, fetch('https://api.example.com/data') was cached indefinitely by default (force-cache). In Next.js 15, it defaults to uncached (no-store).
If you want a fetch request to be cached, you must now explicitly opt-in:
// Next.js 15: Opting-in to static data caching
const res = await fetch('https://api.example.com/data', {
next: { revalidate: 3600 } // Cache for 1 hour
});
B. GET Route Handlers
Similarly, Route Handlers using the GET method are no longer cached by default. They will execute dynamically on every request unless you configure them with static route config options or explicit caching headers.
// app/api/status/route.ts
import { NextResponse } from 'next/server';
// This handler runs dynamically on every request in Next.js 15
export async function GET() {
const systemTime = new Date().toISOString();
return NextResponse.json({ status: 'healthy', timestamp: systemTime });
}
// To force static caching for this route handler:
// export const dynamic = 'force-static';
2. Deep Dive: Client Router Cache & staleTimes
The Client Router Cache (CRC) caches rendered React Server Component (RSC) payloads in the browser as you navigate. In Next.js 14, this cache would persist for 5 minutes (for dynamic routes) or 5 minutes (for static routes) during client-side navigation. This often led to users seeing stale data when clicking the back/forward buttons or navigating between pages.
Next.js 15 refines this behavior by introducing configurable staleTimes inside next.config.ts. By default:
- Dynamic routes now have a
staleTimeof 0 seconds. When navigating to a dynamic route, the client will fetch the latest RSC payload from the server every time. - Static routes retain a
staleTimeof 5 minutes.
Customizing staleTimes
If you want to fine-tune client-side cache persistence to reduce server load, you can customize these thresholds in your configuration:
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
experimental: {
staleTimes: {
dynamic: 30, // Cache dynamic route payloads for 30 seconds
static: 180, // Cache static route payloads for 3 minutes
},
},
};
export default nextConfig;
This configuration ensures that if a user clicks back to a dynamically rendered page within 30 seconds, the browser will pull the cached layout/page segment instead of hitting the server immediately.
3. Partial Prerendering (PPR) Architecture
One of the most exciting additions in Next.js 15 is the stabilization path for Partial Prerendering (PPR).
Historically, rendering was a binary choice: either a route was entirely static (built at compile time) or entirely dynamic (computed on every request). If a page had a static blog post but a dynamic comment section, the entire page had to be rendered dynamically, forfeiting CDN edge-caching benefits.
PPR solves this by splitting a route into a Static Shell and Dynamic Holes.
+------------------------------------------+
| Static Shell (Header, Navigation) | <-- Instantly served from Edge CDN
| +------------------------------------+ |
| | Dynamic Hole (Suspense Boundary) | | <-- Streamed from Server as it resolves
| +------------------------------------+ |
| Static Footer |
+------------------------------------------+
Enabling PPR
To use PPR, enable it in your configuration file:
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
experimental: {
ppr: 'incremental', // Opt-in to PPR for specific routes
},
};
export default nextConfig;
Implementing a PPR Route
To declare a static shell with a dynamic hole, you wrap the dynamic components in React's Suspense boundary. Next.js compiles the wrapping page structure statically and defers the rendering of the Suspense fallback content to the server at request time.
Let's write a dashboard page:
// app/dashboard/page.tsx
import { Suspense } from 'react';
import StaticSidebar from '@/components/StaticSidebar';
import StaticHeader from '@/components/StaticHeader';
import RealTimeMetrics from '@/components/RealTimeMetrics';
import MetricSkeleton from '@/components/MetricSkeleton';
// Explicitly opt-in this route to PPR
export const experimental_ppr = true;
export default function DashboardPage() {
return (
<div className="flex h-screen bg-slate-50">
<StaticSidebar />
<div className="flex-1 flex flex-col overflow-hidden">
<StaticHeader title="System Analytics" />
<main className="flex-1 overflow-y-auto p-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<div className="p-6 bg-white rounded-xl shadow-sm border border-slate-100">
<h3 className="text-slate-500 text-sm font-semibold">Total Installs</h3>
<p className="text-3xl font-bold text-slate-800">12,480</p>
</div>
<div className="p-6 bg-white rounded-xl shadow-sm border border-slate-100">
<h3 className="text-slate-500 text-sm font-semibold">Active Sessions</h3>
<p className="text-3xl font-bold text-slate-800">1,204</p>
</div>
{/* Dynamic Hole: Metrics are fetched from a dynamic DB query */}
<Suspense fallback={<MetricSkeleton />}>
<RealTimeMetrics />
</Suspense>
</div>
</main>
</div>
</div>
);
}
Behind the scenes:
- During
next build, Next.js compiles the HTML shell (Sidebar, Header, static cards) and stores it in the.nextoutput. - When a user requests
/dashboard, the Edge immediately streams the static HTML shell. The user sees the skeleton layout in milliseconds. - Simultaneously, the server executes
<RealTimeMetrics />(which contains dynamic database calls). - As soon as the dynamic data resolves, the server streams the rendered component payload, replacing the fallback
<MetricSkeleton />transparently on the client.
4. Server Actions: Caching & Revalidation Flow
Next.js 15 features enhanced integration with React 19's Server Actions. When a Server Action is executed, Next.js utilizes a highly coordinated revalidation system. A common challenge with Server Actions is ensuring that changes made in the database are instantly reflected in the client state.
Here is a robust implementation using Server Actions and the revalidatePath utility:
// app/actions/todo.ts
'use server';
import { revalidatePath } from 'next/cache';
interface Todo {
id: string;
title: string;
completed: boolean;
}
// In-memory store mock for illustration purposes
let todos: Todo[] = [
{ id: '1', title: 'Complete Next.js 15 Upgrade', completed: false }
];
export async function createTodo(formData: FormData) {
const title = formData.get('title') as string;
if (!title || title.trim() === '') {
throw new Error('Title is required');
}
const newTodo: Todo = {
id: Math.random().toString(36).substring(7),
title,
completed: false
};
todos.push(newTodo);
// Invalidate the cache for the todo list page, triggering a server-side re-render
revalidatePath('/todos');
return { success: true };
}
On the client side, we use the useActionState hook from React 19 to handle pending states and form submissions smoothly:
// app/todos/TodoForm.tsx
'use client';
import { useActionState } from 'react';
import { createTodo } from '@/app/actions/todo';
export default function TodoForm() {
// useActionState handles pending state, action execution, and errors
const [state, formAction, isPending] = useActionState(
async (prevState: any, formData: FormData) => {
try {
await createTodo(formData);
return { error: null };
} catch (err: any) {
return { error: err.message };
}
},
{ error: null }
);
return (
<form action={formAction} className="space-y-4 max-w-md bg-white p-6 rounded-lg shadow">
<div>
<label htmlFor="title" className="block text-sm font-medium text-slate-700">
New Task
</label>
<input
type="text"
name="title"
id="title"
className="mt-1 block w-full rounded-md border-slate-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
placeholder="Buy groceries..."
/>
</div>
{state.error && <p className="text-red-500 text-sm">{state.error}</p>}
<button
type="submit"
disabled={isPending}
className="w-full inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
{isPending ? 'Saving...' : 'Add Task'}
</button>
</form>
);
}
5. Summary Checklist: Routing & Caching in v15
To summarize the changes and how to implement them in your workspace:
| Concept / Behavior | Next.js 14 | Next.js 15 | How to configure / opt-in |
|---|---|---|---|
| Fetch Requests | Cached (force-cache) by default |
Uncached (no-store) by default |
Use { next: { revalidate: X } } |
| GET Route Handlers | Cached by default | Uncached by default | Add export const dynamic = 'force-static' |
| Client Router Cache | 5-minute cache for dynamic/static routes | 0s for dynamic, 5m for static routes | Customize staleTimes in next.config.ts |
| Partial Prerendering | Experimental behind flag | Gradual rollout (incremental paths) | Add ppr: 'incremental' and route-level export |
Next.js 15 shifts control back to developers. By avoiding automatic catch-all caches and giving robust tools like custom staleTimes and incremental PPR, you can build applications that are fast, dynamic, and easy to reason about.
