March 2026
·3 min read
Data Fetching in Next.js: Caching, Revalidation & Streaming
Master Next.js 15 data fetching — fetch cache semantics, unstable_cache, on-demand revalidation, and Suspense streaming for faster perceived performance.
Introduction
- Next.js 15 overhauled the
fetchcache model (opt-in instead of opt-out) — oldfetchcache patterns from Next.js 13/14 may now behave differently. - Three pillars of Next.js data fetching: where to fetch (Server Component vs Route Handler), when to refresh (static/dynamic/revalidate), and how to stream (Suspense).
- This guide covers all three for the App Router.
Where to Fetch: Server Components vs Route Handlers
- Server Components are the primary place to fetch data — async/await directly in the component, no API round-trip from the browser.
- Route Handlers (
app/api/route.ts) are for client-initiated fetches, webhooks, and third-party callbacks. - Avoid the "dual-fetch" anti-pattern: fetching in both a Server Component and a Route Handler for the same data.
The fetch Cache in Next.js 15
- Next.js 15 changed the default:
fetchrequests are now uncached by default (cache: 'no-store'). - Opt into caching explicitly:
// Static — cached indefinitely, regenerated on next build
const data = await fetch('https://api.example.com/data', { cache: 'force-cache' });
// Time-based revalidation — cached for 60 seconds
const data = await fetch('https://api.example.com/data', { next: { revalidate: 60 } });
// Always fresh (default in Next.js 15)
const data = await fetch('https://api.example.com/data', { cache: 'no-store' });unstable_cache for Non-Fetch Data Sources
- ORMs, database clients, and SDKs don't go through
fetch— useunstable_cacheto cache their results. - Accepts a key array, tags, and
revalidateinterval.
import { unstable_cache } from 'next/cache';
export const getPosts = unstable_cache(
async () => {
return db.query('SELECT * FROM posts ORDER BY date DESC');
},
['posts-list'],
{ tags: ['posts'], revalidate: 300 }
);On-Demand Revalidation with revalidateTag and revalidatePath
revalidateTag('posts')— invalidates all cached responses tagged with'posts'.revalidatePath('/blog')— marks the cached output for a path as stale.- Call from a Server Action or Route Handler (e.g., a Contentful webhook).
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
import type { NextRequest } from 'next/server';
export async function POST(req: NextRequest) {
const { tag } = await req.json();
revalidateTag(tag);
return Response.json({ revalidated: true });
}Dynamic vs Static Rendering
- A route is statically rendered at build time if all its data fetches are cached (or it has no data fetches).
- A route is dynamically rendered at request time if it uses
cookies(),headers(),searchParams, or uncached fetches. - Check at build time:
next buildoutput shows ○ (static) vs λ (dynamic) per route.
Streaming with Suspense
- Wrap slow data-fetching Server Components in
<Suspense fallback={<Skeleton />}>to stream HTML progressively. - The page shell is sent immediately; deferred components stream in as their data resolves.
- Next.js
loading.tsxis syntactic sugar for a Suspense boundary at the route level.
// app/blog/page.tsx
import { Suspense } from 'react';
import { PostList } from './PostList'; // slow — fetches from CMS
import { PostListSkeleton } from './PostListSkeleton';
export default function BlogPage() {
return (
<main>
<h1>Blog</h1>
<Suspense fallback={<PostListSkeleton />}>
<PostList />
</Suspense>
</main>
);
}Parallel Data Fetching
- Awaiting fetches sequentially creates waterfalls; use
Promise.allto fan out parallel requests.
async function BlogPostPage({ slug }: { slug: string }) {
const [post, related] = await Promise.all([
getPostBySlug(slug),
getRelatedPosts(slug),
]);
// ...
}Prefetching and the <Link> Component
<Link href="/blog">prefetches the linked route in the background on hover/visibility.- For dynamic routes, use
router.prefetch('/blog/my-post')in a"use client"component.
Common Pitfalls
- Fetching in a Client Component with
useEffectfor data that could be fetched in a parent Server Component — unnecessary client JS. - Caching sensitive user-specific data across requests (session data must be uncached or scoped per user).
- Forgetting to call
revalidateTagafter a CMS webhook — pages serve stale content indefinitely.
Conclusion
- Default to fetching in Server Components; reserve Route Handlers for browser-initiated or external calls.
- Use
unstable_cachefor non-fetchsources; tag everything so revalidation is precise. - Wrap slow sections in
<Suspense>— users see a skeleton immediately, content streams in. - Check the build output for unexpected dynamic routes eating into your hosting bill.