Next.js 15 is here, and upgrading is less about a list of new features and more about a fundamental shift in how your application’s server and client code interact.
Let’s see it in action. Imagine a simple app/page.tsx in Next.js 14:
// app/page.tsx (Next.js 14)
async function getData() {
const res = await fetch('https://api.example.com/data');
if (!res.ok) {
throw new Error('Failed to fetch data');
}
return res.json();
}
export default async function Page() {
const data = await getData();
return (
<div>
<h1>Data from API</h1>
<pre>{JSON.stringify(data, null, 2)}</pre>
</div>
);
}
This code works, but the fetch call might be happening on the server or the client depending on your configuration and React’s internal heuristics. In Next.js 15, this pattern becomes explicitly server-centric by default, with clear pathways to opt into client-side fetching.
The core problem Next.js 15 addresses is the inherent ambiguity and potential performance pitfalls of mixed server/client execution. Historically, a fetch call within a React Server Component (RSC) could sometimes hydrate and re-run on the client, leading to unexpected network requests, duplicate data fetching, or inconsistent UI states. Next.js 15 solidifies the boundaries.
By default, any fetch requests made within Server Components in Next.js 15 are guaranteed to execute only on the server. This means your data fetching logic is now predictable and optimized for server-side execution, preventing client-side waterfalls and improving initial page load performance. When you need data fetching on the client, you explicitly opt into it using the client-side fetch pattern, often involving a Client Component that wraps your data-fetching logic and uses a library like SWR or React Query, or a simple useEffect hook.
Here’s how you’d migrate the above example to be explicitly client-side fetched in Next.js 15:
First, create a Client Component for the data display:
// components/DataDisplay.tsx (Next.js 15)
'use client';
import useSWR from 'swr';
const fetcher = (url: string) => fetch(url).then((res) => res.json());
export default function DataDisplay() {
const { data, error } = useSWR('https://api.example.com/data', fetcher);
if (error) return <div>Failed to load data</div>;
if (!data) return <div>Loading...</div>;
return (
<div>
<h1>Data from API</h1>
<pre>{JSON.stringify(data, null, 2)}</pre>
</div>
);
}
Then, import this Client Component into your Server Component app/page.tsx:
// app/page.tsx (Next.js 15)
import DataDisplay from '@/components/DataDisplay';
export default function Page() {
return (
<div>
<h1>My Application</h1>
<DataDisplay />
</div>
);
}
The key here is the 'use client'; directive. This tells Next.js that DataDisplay.tsx is a Client Component and all code within it (including hooks and event handlers) will run in the browser. The fetch call is now definitively client-side.
For data that should be fetched on the server and passed down, you continue to use fetch directly in Server Components, but now with enhanced caching and revalidation options. Next.js 15 leverages the Web Fetch API’s cache and next.revalidate options more robustly.
For example, to fetch data and cache it for 60 seconds:
// app/page.tsx (Next.js 15 - Server-side fetch with revalidation)
async function getData() {
const res = await fetch('https://api.example.com/data', {
next: { revalidate: 60 }, // Revalidate this data every 60 seconds
});
if (!res.ok) {
throw new Error('Failed to fetch data');
}
return res.json();
}
export default async function Page() {
const data = await getData();
return (
<div>
<h1>Data from API (Server Cached)</h1>
<pre>{JSON.stringify(data, null, 2)}</pre>
</div>
);
}
The next: { revalidate: 60 } option tells the Next.js cache to consider this data stale after 60 seconds. On the next request after the cache expires, Next.js will revalidate the data by fetching it from the origin. This is a powerful mechanism for balancing fresh data with performance.
The most significant change for developers is the implicit assumption that components are Server Components by default. You no longer need to explicitly mark them as such unless you’re dealing with a layout.tsx or page.tsx file that might contain client-side interactivity. The 'use client'; directive is the explicit opt-in for client-side rendering. This shifts the mental model from "how do I make this server-side?" to "how do I make this client-side?".
The fetch API itself is now a first-class citizen for data caching and revalidation in Next.js 15. By default, fetch requests are cached indefinitely. You can control this behavior with cache: 'no-store' (always fetch fresh, no caching), cache: 'force-cache' (default, cache indefinitely), or by using the next.revalidate option for time-based revalidation. This makes your data fetching strategy explicit and controllable directly within the fetch call itself, rather than relying on external libraries or implicit framework behavior.
A subtle but crucial aspect is how fetch interactions with route handlers and server actions. When you call fetch inside a Server Component, and that fetch request targets a route handler (/api/...) within your own Next.js application, Next.js will intelligently bypass the network and directly invoke the route handler’s logic. This is an internal optimization that dramatically speeds up inter-API communication within your Next.js app.
When you’ve successfully migrated and are no longer seeing any data fetching issues, the next challenge will be managing the nuances of partial rendering and streaming with RSCs.