Next.js Data Fetching — fetch, cache, revalidate, and ISR
Advertisement
Introduction
Next.js extends the native fetch API with built-in caching and revalidation. In the App Router, data fetching happens inside Server Components — no useEffect, no loading state, no API boilerplate. You call fetch (or query your database) directly inside async components and Next.js handles the rest.
Why This Matters
Poorly implemented data fetching is the most common source of performance problems in Next.js apps. Over-fetching on every request, creating request waterfalls, and skipping caching can easily push your TTFB above 1 second and tank Core Web Vitals scores.
Next.js 15 changed the default caching behavior: fetch no longer caches by default. You must explicitly opt into caching with cache: 'force-cache' or next: { revalidate: N }. Understanding these options is critical for building both fast and up-to-date applications.
The reward for getting this right is significant: pages served from cache respond in under 10ms, compared to 200–800ms for database-driven server renders.
The Three Caching Modes
Next.js fetch supports three caching strategies:
// 1. No caching — fresh data on every request
const data = await fetch('https://api.example.com/live', {
cache: 'no-store',
})
// 2. Cached forever — revalidate manually or on deploy
const data = await fetch('https://api.example.com/config', {
cache: 'force-cache',
})
// 3. Time-based revalidation (ISR) — fresh after N seconds
const data = await fetch('https://api.example.com/posts', {
next: { revalidate: 60 }, // Cache for 60 seconds
})Route Segment Config
Control caching at the page level with exported constants:
// app/blog/[slug]/page.tsx
// Revalidate the entire route every 60 seconds
export const revalidate = 60
// Or opt into dynamic rendering (no caching)
export const dynamic = 'force-dynamic'
// Or full static — never re-fetch after build
export const dynamic = 'force-static'Incremental Static Regeneration (ISR)
ISR pre-renders pages at build time and regenerates them in the background after the revalidation window:
// app/blog/[slug]/page.tsx
export const revalidate = 3600 // Regenerate at most once per hour
export async function generateStaticParams() {
const posts = await fetch('https://cms.example.com/posts').then((r) => r.json())
return posts.map((post: { slug: string }) => ({ slug: post.slug }))
}
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await fetch(`https://cms.example.com/posts/${params.slug}`, {
next: { revalidate: 3600, tags: [`post-${params.slug}`] },
}).then((r) => r.json())
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.contentHtml }} />
</article>
)
}On-Demand Revalidation
Trigger cache invalidation immediately when data changes — ideal for CMS webhooks:
// app/api/revalidate/route.ts
import { revalidateTag, revalidatePath } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'
export async function POST(req: NextRequest) {
const secret = req.nextUrl.searchParams.get('secret')
if (secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ error: 'Invalid secret' }, { status: 401 })
}
const body = await req.json()
const { tag, path } = body
if (tag) revalidateTag(tag)
if (path) revalidatePath(path)
return NextResponse.json({ revalidated: true, at: new Date().toISOString() })
}Tag your fetches so revalidateTag knows what to invalidate:
// lib/api.ts
export async function getPost(slug: string) {
const res = await fetch(`https://cms.example.com/posts/${slug}`, {
next: { tags: ['posts', `post-${slug}`] },
})
return res.json()
}Parallel Data Fetching
Always fetch independent resources in parallel. Sequential fetches are the most common cause of slow pages:
// BAD — sequential waterfall (slow)
export default async function DashboardPage() {
const user = await fetchUser() // 200ms
const posts = await fetchPosts() // 300ms
const stats = await fetchStats() // 150ms
// Total: ~650ms
}
// GOOD — parallel (fast)
export default async function DashboardPage() {
const [user, posts, stats] = await Promise.all([
fetchUser(), // \
fetchPosts(), // } all fire at once
fetchStats(), // /
])
// Total: ~300ms (slowest request)
return (
<div>
<UserCard user={user} />
<PostList posts={posts} />
<StatsPanel stats={stats} />
</div>
)
}Server Action Revalidation
Revalidate after mutations using Server Actions:
// app/actions.ts
'use server'
import { revalidateTag, revalidatePath } from 'next/cache'
import { db } from '@/lib/db'
export async function publishPost(postId: string) {
await db.post.update({
where: { id: postId },
data: { status: 'published', publishedAt: new Date() },
})
// Invalidate all post-related caches
revalidateTag('posts')
revalidateTag(`post-${postId}`)
revalidatePath('/blog')
}Custom Fetch Wrapper
Build a reusable fetch utility with error handling:
// lib/fetch.ts
type FetchOptions = {
cache?: RequestCache
next?: { revalidate?: number; tags?: string[] }
}
export async function apiFetch<T>(endpoint: string, options: FetchOptions = {}): Promise<T> {
const url = `${process.env.API_BASE_URL}${endpoint}`
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${process.env.API_SECRET}`,
'Content-Type': 'application/json',
},
...options,
})
if (!res.ok) {
throw new Error(`API error ${res.status}: ${await res.text()}`)
}
return res.json() as Promise<T>
}
// Usage
const posts = await apiFetch<Post[]>('/posts', { next: { revalidate: 60, tags: ['posts'] } })Deduplication
Next.js automatically deduplicates identical fetch calls within the same server render pass:
// Both components call the same URL — Next.js only fetches once
async function Header() {
const user = await fetch('/api/me').then(r => r.json())
return <div>{user.name}</div>
}
async function Sidebar() {
const user = await fetch('/api/me').then(r => r.json())
return <div>{user.avatar}</div>
}For non-fetch data sources (database queries), use React's cache() function:
import { cache } from 'react'
import { db } from '@/lib/db'
export const getUser = cache(async (id: string) => {
return db.user.findUnique({ where: { id } })
})Common Mistakes
- Not setting
cache: 'no-store'for real-time data (e.g., stock prices, live scores) - Creating request waterfalls with sequential
awaitwhenPromise.allwould work - Forgetting to add
tagsto fetches, making on-demand revalidation impossible - Using
revalidatePath('/')in a Server Action without thinking — this revalidates the entire app - Mixing
dynamic = 'force-static'with dynamic data sources causing stale content
Best Practices
- Tag every fetch with meaningful tags:
['posts'],['post-{id}'],['user-{id}'] - Use
Promise.allfor all independent data sources on a page - Set
revalidateat the route segment level to control the entire page's cache lifetime - Use
no-storesparingly — only for truly real-time data; ISR covers most use cases - Add
generateStaticParamsto pre-render all known dynamic routes at build time - Use the
cache()wrapper from React for database queries to enable deduplication
Key Takeaways
- Next.js 15 no longer caches
fetchby default — you must opt in withforce-cacheorrevalidate next: { revalidate: 60 }enables ISR — the page serves from cache and regenerates in the backgroundrevalidateTag(tag)andrevalidatePath(path)trigger on-demand cache invalidation immediatelyPromise.allfor parallel fetching reduces page load time from the sum of all requests to just the slowest- Next.js deduplicates identical
fetchcalls in the same render — usecache()for DB queries - Tag your fetches with
next: { tags: ['name'] }to enable precise cache invalidation viarevalidateTag generateStaticParamspre-renders dynamic routes at build time, making them lightning-fastexport const dynamic = 'force-dynamic'opts a route into SSR (no caching) for fully real-time pages
Advertisement