Chapter 10

Server Component Data Fetching: fetch, Caching and Revalidation

Why Next.js Extends the Native fetch API

In the browser, fetch is stateless — every call sends a real HTTP request. Node.js 18 introduced a global fetch API, but with no built-in caching semantics. Next.js does something that seems simple but has profound implications: it intercepts and extends fetch, injecting three dimensions of cache control.

The core motivation is this: Server Components run on the server and can fetch data at any level of the component tree without threading props through every intermediate layer. But if each component fires its own independent requests, the same API endpoint might be called dozens of times in a single page render. Next.js's extended fetch exists precisely to solve this problem.

The Three Caching Strategies

force-cache: The Optimal Choice for Static Data

// app/products/page.tsx
async function getProducts() {
  const res = await fetch('https://api.example.com/products', {
    cache: 'force-cache', // default in Next.js 14 and earlier
  })
  if (!res.ok) throw new Error('Failed to fetch products')
  return res.json()
}

export default async function ProductsPage() {
  const products = await getProducts()
  return (
    <ul>
      {products.map((p: { id: string; name: string }) => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  )
}

force-cache stores the response in the Data Cache — a persistent store that, on Vercel, is shared across requests and even across server instances. After the first request, all subsequent requests hit the cache without making a real network call. This is the underlying mechanism by which Next.js implements Static Site Generation semantics.

Critical change in Next.js 15: The default fetch behavior changed from force-cache to no-store. This is a breaking change. If your code relied on the implicit caching default, upgrading requires you to explicitly add cache: 'force-cache'.

no-store: For Live Data

async function getLivePrice(ticker: string) {
  const res = await fetch(`https://api.finance.com/price/${ticker}`, {
    cache: 'no-store', // bypasses cache entirely, always makes a real request
  })
  return res.json()
}

no-store completely bypasses the Data Cache. Every render triggers a real network request. This is appropriate for stock prices, live inventory counts, or any data where staleness is unacceptable. In Next.js 15, this is now the default fetch behavior.

revalidate: Cache with a Time Window

async function getArticles() {
  const res = await fetch('https://cms.example.com/articles', {
    next: { revalidate: 3600 }, // revalidate after 1 hour
  })
  return res.json()
}

This implements ISR (Incremental Static Regeneration) semantics: within the validity window, responses are served from cache with near-zero latency. Once the window expires, the next request triggers a background re-fetch to update the cache while still returning the stale data to the current requester. This is a stale-while-revalidate strategy — the same pattern popularized by HTTP cache-control headers, now built into your data layer.

On-Demand Revalidation: The Tag System

Time-based revalidation has a fundamental limitation: data may change before the window expires, but users still see stale content. The next: { tags } option combined with revalidateTag solves this.

// Tag the request at fetch time
async function getProduct(id: string) {
  const res = await fetch(`https://api.example.com/products/${id}`, {
    next: { tags: ['products', `product-${id}`] },
  })
  return res.json()
}

// Trigger revalidation from a Server Action or Route Handler
import { revalidateTag } from 'next/cache'

export async function updateProduct(id: string, data: unknown) {
  await db.product.update({ where: { id }, data })
  revalidateTag(`product-${id}`) // invalidate only this product's cache
  // revalidateTag('products')   // invalidate all product list caches
}

The tag system makes cache invalidation surgical. In an e-commerce context, updating a single product should only invalidate that product's cache entries — not blow away the entire product catalog cache. Tags give you that granularity.

A single fetch call can carry multiple tags, and revalidateTag invalidates all cache entries carrying that tag, regardless of the URL or request options. This decouples cache invalidation from URL structure.

Request Memoization: Deduplication Within a Render Tree

Above the Data Cache sits an even shorter-lived mechanism: Request Memoization.

// layout.tsx
async function getUser(id: string) {
  const res = await fetch(`/api/users/${id}`)
  return res.json()
}

export default async function Layout({ children }: { children: React.ReactNode }) {
  const user = await getUser('123') // first call — real request goes out
  return <div data-user={user.name}>{children}</div>
}

// page.tsx (in the same render tree)
async function UserProfile() {
  const user = await getUser('123') // same URL — returns memoized result, no network call
  return <div>{user.email}</div>
}

How memoization works internally: Next.js creates a Map at the start of each render. The key is the URL plus a serialized hash of the request options. The value is the in-flight Promise. When two components in the same render tree call fetch with identical arguments, they receive the same Promise — no second network request is issued. After rendering completes, the Map is discarded.

The practical implication is significant: you no longer need to "lift" data fetching to a common ancestor to avoid duplicate requests. You can co-locate each component's data needs with the component itself, and Next.js handles deduplication transparently. This is a fundamental improvement over the traditional React pattern of prop drilling or global state to prevent over-fetching.

Memoization applies to identical GET requests within the same render tree. POST requests are not memoized. Memoization does not persist across separate user requests — it is strictly per-render.

Parallel vs Sequential Data Fetching

Sequential Fetching (The Waterfall Anti-Pattern)

// Anti-pattern: sequential awaits compound latency
export default async function Page({ params }: { params: { id: string } }) {
  const user = await getUser(params.id)         // waits 100ms
  const posts = await getPostsByUser(user.name) // then waits 200ms → total 300ms
  return <PostList user={user} posts={posts} />
}

Sequential await is only necessary when the second request genuinely depends on the result of the first. When there is no data dependency, sequential fetching is a performance bug.

// Recommended: concurrent fetching with Promise.all
export default async function Page({ params }: { params: { id: string } }) {
  // Both requests fire simultaneously — total latency ≈ max(100ms, 200ms) = 200ms
  const [user, latestPosts] = await Promise.all([
    getUser(params.id),
    getLatestPosts(),
  ])
  return <UserDashboard user={user} posts={latestPosts} />
}

Promise.all lets independent requests execute concurrently. In real applications, a single page may require 5–8 different data sources. Using Promise.all reduces total latency from the sum of each request's latency to the maximum of any individual request's latency.

Using Suspense to Break Sequential Dependencies

Sometimes data dependencies are genuine but you don't want to block the entire page on them:

export default function Page({ params }: { params: { id: string } }) {
  return (
    <div>
      <Suspense fallback={<UserSkeleton />}>
        <UserProfile id={params.id} />
      </Suspense>
      <Suspense fallback={<RecommendationsSkeleton />}>
        {/* This component fetches its own data based on userId,
            but it streams in independently from UserProfile */}
        <Recommendations userId={params.id} />
      </Suspense>
    </div>
  )
}

Each Suspense boundary streams independently. The page shell renders immediately, and each section reveals as its data resolves. This transforms a blocking waterfall into concurrent streaming.

Choosing Between fetch and Direct ORM/SDK Access

In Server Components, you won't always fetch data through fetch. Prisma, Drizzle, and third-party SDKs are often more appropriate — but there's an important distinction:

// Via fetch: benefits from request memoization and Data Cache
const data = await fetch('/api/products').then(r => r.json())

// Direct Prisma: bypasses fetch entirely — no memoization, no Data Cache
const products = await prisma.product.findMany()

The cost of bypassing fetch: no request memoization. If multiple components in the render tree call the same Prisma query, each call issues a real database query. For direct database access, you need unstable_cache.

unstable_cache: Data Cache for Non-fetch Sources

unstable_cache is Next.js's mechanism for applying Data Cache semantics to arbitrary async functions — anything that isn't a fetch call:

import { unstable_cache } from 'next/cache'
import { prisma } from '@/lib/prisma'

const getCachedProducts = unstable_cache(
  async () => {
    return prisma.product.findMany({
      where: { published: true },
      orderBy: { createdAt: 'desc' },
    })
  },
  ['products-list'],   // cache key (array for namespace management)
  {
    revalidate: 3600,  // revalidate after 1 hour
    tags: ['products'], // supports revalidateTag for on-demand invalidation
  }
)

export default async function ProductsPage() {
  const products = await getCachedProducts()
  return <ProductGrid products={products} />
}

unstable_cache uses the same underlying Data Cache store as fetch. This means revalidateTag('products') can simultaneously invalidate both fetch responses tagged with 'products' and unstable_cache results carrying the same tag. The two mechanisms are unified at the cache layer.

The unstable_ prefix does not mean "avoid in production." It signals that the API contract may change in future Next.js versions. The functionality is production-ready; monitor the Next.js changelog when upgrading.

Decision Framework

When choosing a data fetching strategy:

Rarely updated data (product descriptions, documentation): use cache: 'force-cache' with revalidateTag for on-demand invalidation when content changes.

Periodically updated data (blog posts, pricing): use next: { revalidate: N } combined with next: { tags: [...] } for time-based plus on-demand invalidation.

Real-time data (inventory counts, personalized user data): use cache: 'no-store', or use cookies() / headers() which automatically opt the route into dynamic rendering.

Database queries via ORM: wrap with unstable_cache to gain the same cache capabilities as fetch-based data fetching.

The key insight behind Next.js's caching model is that performance and data freshness are not mutually exclusive. Through the tag-based invalidation system, you can cache aggressively for performance while maintaining the ability to invalidate precisely when the underlying data changes. This is fundamentally different from the blunt instrument of "either cache everything or cache nothing."

Rate this chapter
4.5  / 5  (33 ratings)

💬 Comments