Next.js Data Fetching — fetch, cache, revalidate, and ISR

Sanjeev SharmaSanjeev Sharma
6 min read

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 await when Promise.all would work
  • Forgetting to add tags to 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.all for all independent data sources on a page
  • Set revalidate at the route segment level to control the entire page's cache lifetime
  • Use no-store sparingly — only for truly real-time data; ISR covers most use cases
  • Add generateStaticParams to 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 fetch by default — you must opt in with force-cache or revalidate
  • next: { revalidate: 60 } enables ISR — the page serves from cache and regenerates in the background
  • revalidateTag(tag) and revalidatePath(path) trigger on-demand cache invalidation immediately
  • Promise.all for parallel fetching reduces page load time from the sum of all requests to just the slowest
  • Next.js deduplicates identical fetch calls in the same render — use cache() for DB queries
  • Tag your fetches with next: { tags: ['name'] } to enable precise cache invalidation via revalidateTag
  • generateStaticParams pre-renders dynamic routes at build time, making them lightning-fast
  • export const dynamic = 'force-dynamic' opts a route into SSR (no caching) for fully real-time pages

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading