React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

data-fetchingserver-componentsfetchcacheasync

Data Fetching in Server Components

Fetch data directly inside async Server Components with built-in caching and deduplication.

Recipe

Quick-reference recipe card -- copy-paste ready.

// app/posts/page.tsx (Server Component -- the default)
export default async function PostsPage() {
  // Cached by default (force-cache)
  const res = await fetch("https://api.example.com/posts");
  const posts: Post[] = await res.json();
 
  return (
    <ul>
      {posts.map((p) => (
        <li key={p.id}>{p.title}</li>
      ))}
    </ul>
  );
}
 
// Time-based revalidation
await fetch(url, { next: { revalidate: 60 } });
 
// No caching -- always fresh
await fetch(url, { cache: "no-store" });
 
// Tag-based revalidation
await fetch(url, { next: { tags: ["posts"] } });

When to reach for this: You need to load data for a page or component that has no interactivity -- no useState, no onClick, no browser APIs.

Working Example

// lib/api.ts
export type Post = { id: number; title: string; body: string };
 
export async function getPosts(): Promise<Post[]> {
  const res = await fetch("https://jsonplaceholder.typicode.com/posts", {
    next: { revalidate: 300, tags: ["posts"] },
  });
 
  if (!res.ok) throw new Error("Failed to fetch posts");
  return res.json();
}
 
export async function getPost(id: number): Promise<Post> {
  const res = await fetch(
    `https://jsonplaceholder.typicode.com/posts/${id}`,
    { next: { tags: [`post-${id}`] } }
  );
 
  if (!res.ok) throw new Error(`Post ${id} not found`);
  return res.json();
}
// app/posts/page.tsx
import { getPosts } from "@/lib/api";
import Link from "next/link";
 
export default async function PostsPage() {
  const posts = await getPosts();
 
  return (
    <main className="max-w-2xl mx-auto p-6">
      <h1 className="text-2xl font-bold mb-4">Posts</h1>
      <ul className="space-y-2">
        {posts.slice(0, 10).map((post) => (
          <li key={post.id}>
            <Link
              href={`/posts/${post.id}`}
              className="text-blue-600 hover:underline"
            >
              {post.title}
            </Link>
          </li>
        ))}
      </ul>
    </main>
  );
}
// app/posts/[id]/page.tsx
import { getPost } from "@/lib/api";
import { notFound } from "next/navigation";
 
type Props = { params: Promise<{ id: string }> };
 
export default async function PostPage({ params }: Props) {
  const { id } = await params;
  const numericId = Number(id);
  if (Number.isNaN(numericId)) notFound();
 
  const post = await getPost(numericId);
 
  return (
    <article className="max-w-2xl mx-auto p-6">
      <h1 className="text-3xl font-bold mb-2">{post.title}</h1>
      <p className="text-gray-700 leading-relaxed">{post.body}</p>
    </article>
  );
}

What this demonstrates:

  • Fetching data directly in async Server Components -- no useEffect, no client-side loading state
  • Centralizing fetch logic in a lib/ file for reuse across components
  • Using next.revalidate for time-based ISR and next.tags for on-demand revalidation
  • Accessing dynamic route params as a Promise (Next.js 15+ pattern)

Deep Dive

How It Works

  • In the App Router, every component is a Server Component by default. Server Components can be async and call await fetch() directly in the function body.
  • Next.js extends the native fetch API with cache and next options. The default behavior in Next.js 15+ is cache: "auto", which lets the framework decide based on context.
  • When the same URL is fetched multiple times during a single server render pass, React deduplicates the requests automatically via request memoization -- only one network call is made.
  • Fetched data can be stored in the Data Cache, which persists across requests and deployments until revalidated.
  • Errors thrown during fetch can be caught by the nearest error.tsx boundary.

Variations

Parallel data fetching (avoid waterfalls):

export default async function DashboardPage() {
  // Start both fetches simultaneously
  const [users, orders] = await Promise.all([
    fetch("https://api.example.com/users").then((r) => r.json()),
    fetch("https://api.example.com/orders").then((r) => r.json()),
  ]);
 
  return (
    <>
      <UserTable users={users} />
      <OrderList orders={orders} />
    </>
  );
}

Non-fetch data sources (database, ORM):

import { cache } from "react";
import { db } from "@/lib/db";
 
// Wrap with React.cache for request-level memoization
export const getUser = cache(async (id: string) => {
  return db.user.findUnique({ where: { id } });
});

Passing a promise to a Client Component:

// Server Component
export default async function Page() {
  const dataPromise = fetchSlowData(); // do NOT await
  return <ClientChart dataPromise={dataPromise} />;
}

TypeScript Notes

// Always type your fetch responses
type ApiResponse<T> = { data: T; total: number };
 
async function getItems(): Promise<ApiResponse<Item[]>> {
  const res = await fetch("/api/items");
  return res.json();
}
 
// params is a Promise in Next.js 15+
type PageProps = {
  params: Promise<{ slug: string }>;
  searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
};

Gotchas

  • Fetch waterfalls -- Sequential await calls create waterfalls where each fetch waits for the previous one. Fix: Use Promise.all() for independent requests or move each fetch into its own Suspense-wrapped component for parallel streaming.

  • Forgetting error handling -- An uncaught fetch error crashes the entire route. Fix: Throw an error from your fetch helper so the nearest error.tsx boundary catches it, or use try/catch for granular handling.

  • cache: "no-store" makes the entire route dynamic -- If any fetch in a route uses no-store, the whole route opts out of static generation. Fix: Isolate dynamic fetches into separate components wrapped with Suspense so the rest of the route can remain static.

  • Non-fetch calls are not deduplicated automatically -- Direct database queries or third-party SDK calls bypass React's request memoization. Fix: Wrap them with React.cache() to get per-request deduplication.

  • params and searchParams are Promises in Next.js 15+ -- Destructuring them directly without await gives you a Promise object, not the values. Fix: Always await params and await searchParams before accessing properties.

Alternatives

AlternativeUse WhenDon't Use When
Route Handlers (API routes)You need a standalone REST endpoint for external consumersYou only need data inside a Server Component
SWR or React Query on the clientYou need real-time updates, polling, or optimistic mutationsData can be fetched once on the server
Server ActionsYou need to mutate data, not read itYou only need GET requests
React.cache + ORMYou fetch from a database, not an HTTP APIYou are calling a public REST API
unstable_cache (Next.js)You need Data Cache semantics for non-fetch data sourcesPlain fetch with next.revalidate already works

FAQs

Why can Server Components be async but Client Components cannot?
  • Server Components run on the server where async/await is natively supported
  • Client Components are rendered in the browser where React does not support async function components
  • Use useEffect or libraries like SWR for async data fetching in Client Components
What is the difference between cache: "no-store" and next: { revalidate: 0 }?
  • Both opt the route into dynamic rendering
  • cache: "no-store" bypasses the Data Cache entirely
  • revalidate: 0 also skips caching but signals intent through the ISR API
  • In practice they behave the same way in Next.js 15+
How does React request memoization work with fetch?
  • When the same URL and options are fetched multiple times during a single server render, React deduplicates them automatically
  • Only one network call is made; all callers receive the same result
  • Memoization is cleared after the render completes -- it does not persist across requests
When should you use React.cache() instead of relying on automatic fetch deduplication?
  • Use React.cache() for non-fetch data sources like direct database queries or ORM calls
  • Automatic deduplication only applies to the fetch API
  • React.cache() provides per-request memoization for any async function
How do you type the params prop in a dynamic route page component?
// Next.js 15+: params is a Promise
type Props = { params: Promise<{ id: string }> };
 
export default async function Page({ params }: Props) {
  const { id } = await params;
  // use id
}
What happens if you destructure params without awaiting it in Next.js 15+?
  • You get a Promise object instead of the actual values
  • Your code will silently fail or produce unexpected results like "[object Promise]"
  • Always await params before accessing properties
How do you avoid fetch waterfalls when you have multiple independent data fetches?
// Use Promise.all to run fetches in parallel
const [users, orders] = await Promise.all([
  fetch("/api/users").then((r) => r.json()),
  fetch("/api/orders").then((r) => r.json()),
]);
Why does using cache: "no-store" on one fetch make the entire route dynamic?
  • Next.js detects that the route depends on request-time data
  • A single dynamic fetch prevents the entire route from being statically generated
  • Fix: isolate the dynamic fetch into a separate component wrapped with <Suspense>
How should you type a fetch response to ensure type safety?
type ApiResponse<T> = { data: T; total: number };
 
async function getItems(): Promise<ApiResponse<Item[]>> {
  const res = await fetch("/api/items");
  if (!res.ok) throw new Error("Failed to fetch");
  return res.json();
}
What is the default caching behavior of fetch in Next.js 15+?
  • The default is cache: "auto", which lets the framework decide based on context
  • This differs from Next.js 14 where the default was force-cache
  • Be explicit by setting cache or next.revalidate on every fetch call
How do you pass fetched data from a Server Component to a Client Component without awaiting it?
// Server Component: pass the promise, not the resolved value
export default async function Page() {
  const dataPromise = fetchSlowData(); // do NOT await
  return <ClientChart dataPromise={dataPromise} />;
}
  • The Client Component can consume the promise using the use() hook from React
What happens if a fetch call throws an error and there is no error handling?
  • The error crashes the entire route
  • The nearest error.tsx boundary catches it if one exists
  • Always throw from fetch helpers or use try/catch for granular handling