XplormityXplormity
HomeHandbooks
Browse
Xplormity

TLDR developer handbooks for
seasoned developers.

Handbooks

RustNestJSNext.jsGitDockerTypeScriptReactNode.jsDSASQLSystem DesignTailwind CSS

Site

HomeHandbooksAboutPrivacyTerms

Connect

GitHubTwitterLinkedIn

© 2026 Xplormity. All rights reserved.

HandbooksNext.jsData Fetching & Caching Patterns

Data Fetching & Caching Patterns

data-fetchingcachingISRrevalidationhandbook

TL;DR

  • Next.js 16 defaults to no caching — you explicitly opt in.
  • "use cache" directive enables the new Cache Components system.
  • Revalidation: time-based (revalidate: 60) or on-demand (revalidateTag).
  • Parallel fetching with Promise.all or async component composition.
  • Use unstable_cache / "use cache" for server-side data caching.

Step 1: fetch() in Server Components

Server Components fundamentally changed data fetching in React — instead of useEffect + loading states on the client, you simply await directly in the component. This was invented because client-side fetching creates waterfalls (component renders → triggers fetch → shows spinner → renders data) and exposes API keys to the browser. Server Components fetch on the server, stream the result to the client, and never expose credentials. Next.js extends the native fetch API with caching and revalidation options, giving you fine-grained control over freshness vs performance.

// app/users/page.tsx — Server Component (default)
export default async function UsersPage() {
  // Direct fetch in server components — runs on server only
  const res = await fetch('https://api.example.com/users', {
    // Caching options:
    cache: 'no-store',        // Always fresh (default in Next.js 16)
    // cache: 'force-cache',  // Cache indefinitely
    // next: { revalidate: 60 }, // Revalidate every 60 seconds
  });
  const users = await res.json();

  return (
    <ul>
      {users.map((user: User) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

Revalidation Strategies

// Time-based revalidation (ISR - Incremental Static Regeneration)
const res = await fetch('https://api.example.com/posts', {
  next: { revalidate: 3600 } // Regenerate at most every hour
});

// Tag-based revalidation (on-demand)
const res = await fetch('https://api.example.com/posts', {
  next: { tags: ['posts'] }  // Can be invalidated by tag
});

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

async function publishPost() {
  'use server';
  await db.createPost(data);
  revalidateTag('posts');        // Invalidate all fetches tagged 'posts'
  revalidatePath('/blog');       // Revalidate specific path
}

Step 2: "use cache" — Cache Components

The "use cache" directive was created because Next.js needed a way to mark specific components as cacheable without the complexity of full-page static generation. It's the successor to getStaticProps and PPR — simpler, more granular, and composable. When you add "use cache" to a component, Next.js pre-renders it at build time (or on first request) and serves the cached HTML for subsequent requests, only re-rendering when you explicitly invalidate. This gives you static-site performance with dynamic-site flexibility.

// The new cache directive (Next.js 16+)
// Replaces PPR (Partial Pre-Rendering)

// Cache an entire component's output
async function ProductList() {
  "use cache";
  // This component's HTML is cached and reused across requests
  const products = await db.getProducts();
  return (
    <div>
      {products.map(p => <ProductCard key={p.id} product={p} />)}
    </div>
  );
}

// Cache with revalidation
async function DashboardStats() {
  "use cache";
  // @ts-expect-error — experimental API
  cacheLife("minutes"); // Revalidate every few minutes

  const stats = await db.getDashboardStats();
  return <StatsGrid stats={stats} />;
}

// Cache with tags for on-demand invalidation
async function UserProfile({ userId }: { userId: string }) {
  "use cache";
  cacheTag(`user-${userId}`);

  const user = await db.getUser(userId);
  return <ProfileCard user={user} />;
}

Step 3: Parallel Data Fetching

Parallel data fetching exists because sequential awaits are the silent performance killer in Server Components. If you await three independent API calls one after another, you get their latencies summed (300ms + 200ms + 400ms = 900ms). With Promise.all, they run simultaneously and you only wait for the slowest one (400ms). This pattern was always important, but it's even more critical in Server Components where there's no client-side cache to hide the waterfall. Next.js's streaming architecture means each resolved promise can flush HTML immediately.

// ❌ Sequential — slow (each waits for previous)
async function Dashboard() {
  const user = await getUser();        // 200ms
  const posts = await getPosts();      // 300ms
  const analytics = await getAnalytics(); // 250ms
  // Total: 750ms ❌
}

// ✅ Parallel — fast (all at once)
async function Dashboard() {
  const [user, posts, analytics] = await Promise.all([
    getUser(),        // 200ms
    getPosts(),       // 300ms ← longest determines total
    getAnalytics(),   // 250ms
  ]);
  // Total: 300ms ✅
}

// ✅ Even better — component-level parallelism with Suspense
async function Dashboard() {
  return (
    <div>
      <Suspense fallback={<Skeleton />}>
        <UserCard />        {/* Fetches independently */}
      </Suspense>
      <Suspense fallback={<Skeleton />}>
        <PostsList />       {/* Fetches independently */}
      </Suspense>
      <Suspense fallback={<Skeleton />}>
        <AnalyticsPanel />  {/* Fetches independently */}
      </Suspense>
    </div>
  );
}
// Each component fetches its own data and streams in when ready

Step 4: Data Fetching Patterns

These patterns represent the four main ways to get data in a Next.js 16 app, each optimized for different scenarios. Direct database access in Server Components eliminates the API layer entirely (fastest, simplest). Server Actions handle mutations with built-in form integration. Route Handlers serve as traditional API endpoints for client components or external consumers. Choosing the right pattern for each use case is what Next.js interviews test — it shows you understand the server/client boundary and when to cross it.

Pattern 1: Database Direct (Server Components)

// No API needed — query DB directly in server components
import { db } from '@/lib/db';

async function ProductPage({ params }: { params: { id: string } }) {
  const product = await db.product.findUnique({
    where: { id: params.id },
    include: { reviews: { take: 10, orderBy: { createdAt: 'desc' } } },
  });

  if (!product) notFound();

  return <ProductDetail product={product} />;
}

Pattern 2: Server Actions (Mutations)

// app/actions.ts
'use server';

import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;

  // Validate
  if (!title || title.length < 3) {
    return { error: 'Title must be at least 3 characters' };
  }

  // Create in DB
  const post = await db.post.create({
    data: { title, content, authorId: getCurrentUser().id },
  });

  // Invalidate cache & redirect
  revalidatePath('/blog');
  redirect(`/blog/${post.slug}`);
}

// Usage in component
function CreatePostForm() {
  return (
    <form action={createPost}>
      <input name="title" required />
      <textarea name="content" />
      <button type="submit">Publish</button>
    </form>
  );
}

Pattern 3: Route Handlers (API Routes)

// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const page = parseInt(searchParams.get('page') ?? '1');
  const limit = 20;

  const posts = await db.post.findMany({
    skip: (page - 1) * limit,
    take: limit,
    orderBy: { createdAt: 'desc' },
  });

  return NextResponse.json({
    data: posts,
    meta: { page, limit },
  });
}

export async function POST(request: NextRequest) {
  const body = await request.json();

  // Validate with Zod
  const parsed = postSchema.safeParse(body);
  if (!parsed.success) {
    return NextResponse.json({ error: parsed.error.issues }, { status: 400 });
  }

  const post = await db.post.create({ data: parsed.data });
  return NextResponse.json({ data: post }, { status: 201 });
}

Step 5: Loading & Error States

Loading and error states in Next.js use React Suspense boundaries mapped to the file system (loading.tsx, error.tsx). This convention was created because every page needs loading UI and error handling, but developers kept forgetting to add them or implemented them inconsistently. By making them file-based conventions, Next.js guarantees every route segment has graceful degradation. The loading file shows instantly as a shell while the page streams in, and the error file catches any thrown error without crashing the entire app.

// app/blog/loading.tsx — automatic loading UI
export default function Loading() {
  return (
    <div className="space-y-4">
      {Array.from({ length: 5 }).map((_, i) => (
        <div key={i} className="h-24 bg-muted animate-pulse rounded-lg" />
      ))}
    </div>
  );
}

// app/blog/error.tsx — automatic error boundary
'use client';

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div className="text-center py-10">
      <h2>Something went wrong!</h2>
      <p className="text-muted-foreground">{error.message}</p>
      <button onClick={() => reset()}>Try again</button>
    </div>
  );
}

// app/blog/not-found.tsx — 404 page
export default function NotFound() {
  return (
    <div className="text-center py-20">
      <h1 className="text-4xl font-bold">404</h1>
      <p>Post not found</p>
    </div>
  );
}

Step 6: Streaming & Suspense

Streaming was the architectural breakthrough that made Server Components practical. Without it, the server would need to finish ALL data fetching before sending ANY HTML — meaning the slowest query determines your Time to First Byte. With streaming, Next.js sends the cached/static shell immediately and streams in dynamic sections as their data resolves. Users see a fast initial paint with loading states that progressively fill in. This is fundamentally better than both SSR (blocks on data) and CSR (shows blank page then spinner).

import { Suspense } from 'react';

// Page shells render instantly, data streams in
export default function BlogPage() {
  return (
    <div>
      {/* This renders immediately */}
      <h1>Blog</h1>
      <SearchBar />

      {/* This streams in when data is ready */}
      <Suspense fallback={<PostsSkeleton />}>
        <PostsList />
      </Suspense>

      {/* This streams independently */}
      <Suspense fallback={<SidebarSkeleton />}>
        <Sidebar />
      </Suspense>
    </div>
  );
}

// Each async component fetches its own data
async function PostsList() {
  const posts = await db.post.findMany({ take: 20 }); // Slow query
  return posts.map(p => <PostCard key={p.id} post={p} />);
}

async function Sidebar() {
  const popular = await db.post.findMany({
    orderBy: { views: 'desc' },
    take: 5,
  });
  return <PopularPosts posts={popular} />;
}

Interview Questions

  1. What's the difference between cache: 'force-cache' and next: { revalidate: 60 }?

    • force-cache caches indefinitely (until build/redeploy). revalidate: 60 serves cached content but regenerates in background if >60s old (ISR pattern). Next.js 16 defaults to no-store (no caching).
  2. How do Server Components fetch data differently?

    • They run only on the server, so they can directly query databases, access env vars, and use await without sending data-fetching code to the client. No useEffect, no loading states needed (Suspense handles it).
  3. When would you use Route Handlers vs Server Actions?

    • Route Handlers: for third-party webhooks, public APIs, GET requests with query params. Server Actions: for form mutations, data writes, anything triggered by user action that modifies data.
  4. How does streaming work in Next.js?

    • The page shell and static content are sent immediately. Async components wrapped in <Suspense> stream their HTML when ready. Users see content progressively instead of waiting for everything.
Server Actions & Forms

On this page

  • TL;DR
  • Step 1: fetch() in Server Components
  • Revalidation Strategies
  • Step 2: "use cache" — Cache Components
  • Step 3: Parallel Data Fetching
  • Step 4: Data Fetching Patterns
  • Pattern 1: Database Direct (Server Components)
  • Pattern 2: Server Actions (Mutations)
  • Pattern 3: Route Handlers (API Routes)
  • Step 5: Loading & Error States
  • Step 6: Streaming & Suspense
  • Interview Questions