Next.js 15 flipped a default that broke half the "Next.js caching" tutorials on the internet overnight.
fetch requests are no longer cached by default. If you learned Next.js caching before mid-2024, most of what you know is now the opposite of what actually happens.
Here's how I fetch and cache data in every Next.js 15 project now.
1. The Default Changed — Know What You're Getting
In the Pages Router and early App Router, fetch was cached automatically. In Next.js 15, it isn't:
// Next.js 15 default: fetched fresh on every request
async function getPosts() {
const res = await fetch('https://api.example.com/posts');
return res.json();
}
If you want caching, you now ask for it explicitly:
// Cached indefinitely, until manually revalidated
async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
cache: 'force-cache',
});
return res.json();
}
The mental model flipped from "cached unless told otherwise" to "fresh unless told otherwise." This alone explains most "why is my data stale/not stale" bug reports from pre-15 code.
2. Time-Based Revalidation
For content that changes occasionally — blog posts, pricing pages, product listings — use next.revalidate instead of full caching:
// lib/queries/posts.ts
export async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
next: { revalidate: 3600 }, // revalidate every hour
});
return res.json();
}
This is Incremental Static Regeneration under the hood — the page serves a cached version instantly, and Next.js regenerates it in the background once the window passes. Users never wait on a rebuild.
3. Tag-Based Revalidation (The Pattern I Use Most)
Time-based revalidation is fine for content that changes on a schedule. For content that changes because of a user action — someone publishes a post, updates a profile — tag-based revalidation is more precise:
// lib/queries/posts.ts
export async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] },
});
return res.json();
}
export async function getPost(slug: string) {
const res = await fetch(`https://api.example.com/posts/${slug}`, {
next: { tags: ['posts', `post-${slug}`] },
});
return res.json();
}
// actions/posts.ts
'use server';
import { revalidateTag } from 'next/cache';
export async function publishPost(formData: FormData) {
const slug = formData.get('slug') as string;
await db.posts.update({ where: { slug }, data: { published: true } });
// Invalidate the list and the specific post — nothing else
revalidateTag('posts');
revalidateTag(`post-${slug}`);
}
Tag every fetch by what it depends on, then invalidate exactly those tags after a mutation. No guessing which paths need revalidatePath, no over-invalidating the whole site.
4. revalidatePath vs revalidateTag
Both exist for different jobs, and mixing them up causes either stale pages or unnecessary full rebuilds:
import { revalidatePath, revalidateTag } from 'next/cache';
// Use revalidatePath when you know the exact route that needs refreshing
revalidatePath('/dashboard/posts');
// Use revalidateTag when multiple routes share the same underlying data
revalidateTag('posts'); // could affect /blog, /dashboard/posts, /sitemap.xml
If one database change only affects one page, revalidatePath is simpler. If the same data feeds a blog index, an RSS feed, and a dashboard, tag it once and let revalidateTag clear all three in one call.
5. Parallel vs. Sequential Fetching
The most common performance mistake I see in App Router code — fetching data one request at a time when it could happen in parallel:
// ❌ Sequential — each fetch waits for the last one
async function Page() {
const user = await getUser();
const posts = await getPosts(user.id);
const comments = await getComments(user.id);
return <Dashboard user={user} posts={posts} comments={comments} />;
}
// ✅ Parallel — all three fire at once
async function Page() {
const [user, posts, comments] = await Promise.all([
getUser(),
getPosts(),
getComments(),
]);
return <Dashboard user={user} posts={posts} comments={comments} />;
}
If posts and comments don't actually depend on user, the sequential version is paying for three round trips back to back for no reason.
6. Streaming with Suspense for Slow Data
When one piece of data is genuinely slow and the rest of the page doesn't need to wait for it, don't block the whole route — stream it in:
// app/dashboard/page.tsx
import { Suspense } from 'react';
export default function DashboardPage() {
return (
<div>
<Header />
<Suspense fallback={<AnalyticsSkeleton />}>
<SlowAnalyticsPanel />
</Suspense>
</div>
);
}
async function SlowAnalyticsPanel() {
const data = await getAnalytics(); // takes 2-3s
return <AnalyticsChart data={data} />;
}
The header and layout render immediately. The analytics panel streams in once its data resolves, with a skeleton in the meantime — no full-page loading spinner blocking everything for one slow query.
7. Client-Side Fetching Still Has a Place
Server-side fetching covers most pages, but anything that needs to refetch based on user interaction — search-as-you-type, polling, infinite scroll — still belongs on the client:
// hooks/useFetch.ts
import { useState, useEffect } from 'react';
export function useFetch<T>(url: string) {
const [state, setState] = useState<{ data: T | null; loading: boolean }>({
data: null,
loading: true,
});
useEffect(() => {
let cancelled = false;
fetch(url)
.then((res) => res.json())
.then((data) => !cancelled && setState({ data, loading: false }));
return () => {
cancelled = true;
};
}, [url]);
return state;
}
Rule of thumb: if the data is known at render time, fetch it on the server. If it depends on something the user does after the page loads, fetch it on the client.
Summary
| Pattern | Use it for |
|---------|-----------|
| cache: 'force-cache' | Data that rarely changes |
| next: { revalidate: N } | Content that changes on a schedule |
| next: { tags: [...] } + revalidateTag | Content invalidated by user actions, shared across routes |
| revalidatePath | A single, known route needing a refresh |
| Promise.all | Independent fetches that don't need to block each other |
| Suspense streaming | One slow fetch that shouldn't block the rest of the page |
| Client-side fetch/hooks | Data driven by user interaction after load |
The single biggest mindset shift from pre-15 Next.js: nothing is cached until you say so. Once that clicks, the rest of the caching API is just picking the right tool for how often the data actually changes.
I use this exact fetching and revalidation setup across my SaaS dashboards and templates.
Get the templates: https://pixelanas.gumroad.com
Anas — full-stack Next.js developer building SaaS products and premium templates.