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.revalidatefor time-based ISR andnext.tagsfor on-demand revalidation - Accessing dynamic route
paramsas 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
asyncand callawait fetch()directly in the function body. - Next.js extends the native
fetchAPI withcacheandnextoptions. The default behavior in Next.js 15+ iscache: "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.tsxboundary.
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
awaitcalls create waterfalls where each fetch waits for the previous one. Fix: UsePromise.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.tsxboundary catches it, or use try/catch for granular handling. -
cache: "no-store"makes the entire route dynamic -- If any fetch in a route usesno-store, the whole route opts out of static generation. Fix: Isolate dynamic fetches into separate components wrapped withSuspenseso 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
awaitgives you a Promise object, not the values. Fix: Alwaysawait paramsandawait searchParamsbefore accessing properties.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Route Handlers (API routes) | You need a standalone REST endpoint for external consumers | You only need data inside a Server Component |
| SWR or React Query on the client | You need real-time updates, polling, or optimistic mutations | Data can be fetched once on the server |
| Server Actions | You need to mutate data, not read it | You only need GET requests |
React.cache + ORM | You fetch from a database, not an HTTP API | You are calling a public REST API |
unstable_cache (Next.js) | You need Data Cache semantics for non-fetch data sources | Plain 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
useEffector 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 entirelyrevalidate: 0also 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
fetchAPI 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 paramsbefore 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
cacheornext.revalidateon 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.tsxboundary catches it if one exists - Always throw from fetch helpers or use try/catch for granular handling
Related
- Server Actions -- Mutating data from the server
- Revalidation -- Refreshing cached data on demand
- Caching -- Understanding the four caching layers
- Streaming -- Loading data progressively with Suspense