Mark Lowel Montealto — Full Stack Developer & DevOps Engineer

Mark Lowel
Montealto

Full Stack Developer & DevOps Engineer

Blog

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 fetch cache model (opt-in instead of opt-out) — old fetch cache 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: fetch requests 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 — use unstable_cache to cache their results.
  • Accepts a key array, tags, and revalidate interval.
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 build output 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.tsx is 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.all to fan out parallel requests.
async function BlogPostPage({ slug }: { slug: string }) {
  const [post, related] = await Promise.all([
    getPostBySlug(slug),
    getRelatedPosts(slug),
  ]);
  // ...
}
  • <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 useEffect for 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 revalidateTag after 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_cache for non-fetch sources; 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.