Async Server Components
Server Components can be async functions, letting you await data directly in JSX without useEffect or useState boilerplate.
// app/posts/page.tsx
export default async function PostsPage() {
const posts = await fetch('https://api.example.com/posts').then(r => r.json());
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}
Next.js extends the native fetch with caching options: force-cache, no-store, and revalidate.
Fetching Data in Server Components
Data fetching is the heart of almost every web application. You need to get data from
somewhere — a database, a third-party API, a CMS, a file — and render it for the user.
In the Pages Router this meant getServerSideProps, getStaticProps,
and a lot of boilerplate. In the App Router, data fetching is dramatically simpler and
more powerful.
In this lesson you'll learn every data fetching pattern the App Router supports —
from the simplest async component to parallel fetching, sequential fetching, and
the extended fetch API with caching options.
1. The Simplest Way — Async Server Components
The most fundamental change in the App Router is that components can be
async functions. That means you can await data directly
inside JSX — no useEffect, no loading state, no API route in the middle:
// app/users/page.tsx
export default async function UsersPage() {
const res = await fetch("https://jsonplaceholder.typicode.com/users");
const users = await res.json();
return (
<ul>
{users.map((user: { id: number; name: string; email: string }) => (
<li key={user.id}>
<strong>{user.name}</strong> — {user.email}
</li>
))}
</ul>
);
}
This runs entirely on the server. The browser receives a fully rendered HTML list of users — no JavaScript needed to display the data, no loading flicker, no waterfall of network requests from the client.
2. Fetching Directly from a Database
Because Server Components run on the server, you can query your database directly — no API route, no HTTP overhead, no CORS setup:
// app/posts/page.tsx
import { db } from "@/lib/db"; // Your Prisma or Drizzle client
export default async function PostsPage() {
const posts = await db.post.findMany({
where: { published: true },
orderBy: { createdAt: "desc" },
include: { author: true },
});
return (
<main>
<h1>Blog Posts</h1>
{posts.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>By {post.author.name}</p>
</article>
))}
</main>
);
}
This is the single biggest developer experience improvement in the App Router. Your component is the data layer. No intermediate API, no prop drilling, no global state management needed just to get data onto the screen.
3. The Extended fetch API
Next.js extends the native browser fetch function with extra options
that control caching behaviour. These options are what let you choose between
always-fresh data, cached data, and periodically revalidated data — on a
per-request basis.
Option 1: No Store (Always Fresh)
const res = await fetch("https://api.example.com/prices", {
cache: "no-store",
});
no-store means never cache this response. Every request to this page
goes to the API fresh. Use this for data that changes frequently and must always
be up to date — stock prices, live scores, inventory counts.
Option 2: Force Cache (Cache Forever)
const res = await fetch("https://api.example.com/countries", {
cache: "force-cache", // This is the default behaviour
});
force-cache caches the response indefinitely — until you manually
revalidate it or redeploy. Use this for data that almost never changes — a list
of countries, configuration options, content from a headless CMS that you
control deployments for.
Option 3: Revalidate (Time-based Freshness)
const res = await fetch("https://api.example.com/products", {
next: { revalidate: 3600 }, // Revalidate at most every 1 hour (3600 seconds)
});
revalidate is the sweet spot for most data. The first request caches
the response. Subsequent requests within the revalidation window return the cached
version instantly. After the window expires, the next request fetches fresh data
and updates the cache. Use this for product listings, blog posts, user profiles —
anything that changes occasionally but doesn't need to be real-time.
Option 4: Revalidate with Tags
const res = await fetch("https://api.example.com/posts", {
next: { tags: ["posts"] }, // Tag this cache entry
});
Tags let you invalidate specific cache entries on demand — from a Server Action or a Route Handler — without waiting for a time window to expire. We'll cover this in detail in Lesson 203 on caching and revalidation.
4. Parallel Data Fetching
One of the most important performance patterns in the App Router. If a page needs data from multiple sources, fetching them one after another (sequentially) means the total wait time is the sum of all fetches. Fetching them in parallel means the total wait is only as long as the slowest one.
The Problem — Sequential Fetching
// ❌ Sequential — slow! Total time = 200ms + 400ms + 300ms = 900ms
export default async function DashboardPage() {
const user = await fetchUser(); // 200ms
const orders = await fetchOrders(); // 400ms
const stats = await fetchStats(); // 300ms
return <Dashboard user={user} orders={orders} stats={stats} />;
}
The Solution — Promise.all
// ✅ Parallel — fast! Total time = max(200ms, 400ms, 300ms) = 400ms
export default async function DashboardPage() {
const [user, orders, stats] = await Promise.all([
fetchUser(), // 200ms ─┐
fetchOrders(), // 400ms ├─ all start at the same time
fetchStats(), // 300ms ─┘
]);
return <Dashboard user={user} orders={orders} stats={stats} />;
}
Promise.all starts all three fetches simultaneously and waits for all
of them to resolve. The total wait time drops from 900ms to 400ms — more than
2x faster. Always reach for Promise.all when fetches are independent
of each other.
Parallel Fetching with Suspense
An even better approach for complex pages is combining parallel fetching with Suspense boundaries from Lesson 105. Each section starts fetching independently and renders as soon as its data is ready — users see content progressively instead of waiting for all data at once:
// app/dashboard/page.tsx
import { Suspense } from "react";
import UserPanel from "./_components/UserPanel";
import OrdersTable from "./_components/OrdersTable";
import StatsGrid from "./_components/StatsGrid";
export default function DashboardPage() {
// No awaits here — each component fetches its own data
return (
<div>
<Suspense fallback={<UserSkeleton />}>
<UserPanel /> {/* fetches user data internally */}
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<OrdersTable /> {/* fetches orders internally */}
</Suspense>
<Suspense fallback={<StatsSkeleton />}>
<StatsGrid /> {/* fetches stats internally */}
</Suspense>
</div>
);
}
// app/dashboard/_components/UserPanel.tsx
export default async function UserPanel() {
const user = await fetchUser(); // Runs in parallel with the other components
return <div>{user.name}</div>;
}
Each component owns its data fetching. They all start at the same time, and each one streams its content to the browser as soon as it's ready. The user sees the fastest section first rather than waiting for everything.
5. Sequential Data Fetching — When You Actually Need It
Sometimes fetches genuinely depend on each other — you need the result of one fetch to make the next. This is called a data waterfall and is sometimes unavoidable:
// app/profile/[username]/page.tsx
export default async function ProfilePage({
params,
}: {
params: Promise<{ username: string }>;
}) {
const { username } = await params;
// Must fetch user first — we need their ID for the next query
const user = await fetchUserByUsername(username);
// Now fetch posts using the user's ID
const posts = await fetchPostsByUserId(user.id);
return (
<div>
<h1>{user.name}</h1>
<PostList posts={posts} />
</div>
);
}
This is fine when sequential fetching is genuinely required. The key is being deliberate about it — don't fetch sequentially out of habit when the fetches are actually independent.
6. Request Memoization — Fetching the Same Data in Multiple Places
A common worry when components own their own data fetching: "If my layout fetches the current user, and my page also fetches the current user, doesn't that make two API calls?"
The answer is no — thanks to request memoization. Next.js
automatically deduplicates identical fetch() calls made during the
same server render. If the same URL with the same options is fetched multiple
times in one request, only one actual network call is made. The rest get the
cached result instantly.
// app/layout.tsx
export default async function RootLayout({ children }) {
const user = await fetchCurrentUser(); // Network call #1
return (
<html>
<body>
<nav>{user.name}</nav>
{children}
</body>
</html>
);
}
// app/dashboard/page.tsx
export default async function DashboardPage() {
const user = await fetchCurrentUser(); // No network call — returns memoized result
return <h1>Welcome back, {user.name}</h1>;
}
This only works for fetch() calls with the same URL and options.
For database queries or ORM calls (Prisma, Drizzle), you need to use React's
cache() function to get the same deduplication behaviour:
// lib/queries.ts
import { cache } from "react";
import { db } from "@/lib/db";
// Wrap the DB call in cache() — now it deduplicates within a single render
export const getCurrentUser = cache(async () => {
return db.user.findFirst({ where: { ... } });
});
// Now you can call getCurrentUser() in layout AND page — only one DB query runs
import { getCurrentUser } from "@/lib/queries";
// app/layout.tsx
const user = await getCurrentUser(); // DB query
// app/dashboard/page.tsx
const user = await getCurrentUser(); // Returns cached result — no second query
7. Fetching Data in Components vs Pages
One of the most liberating patterns in the App Router is that any Server Component can fetch its own data — not just page-level components. This means you can colocate data fetching with the component that actually needs it:
// components/RecentPosts.tsx — a reusable Server Component
export default async function RecentPosts() {
const posts = await db.post.findMany({
take: 5,
orderBy: { createdAt: "desc" },
});
return (
<section>
<h2>Recent Posts</h2>
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</section>
);
}
// app/page.tsx — just compose components, no data fetching needed here
import RecentPosts from "@/components/RecentPosts";
import FeaturedProduct from "@/components/FeaturedProduct";
import Newsletter from "@/components/Newsletter";
export default function HomePage() {
return (
<main>
<RecentPosts /> {/* fetches its own posts */}
<FeaturedProduct /> {/* fetches its own product */}
<Newsletter /> {/* no data fetching needed */}
</main>
);
}
The homepage doesn't need to know anything about what data its children need.
Each component is self-contained and reusable. Drop <RecentPosts />
into any page and it just works — no props to thread, no parent to update.
8. Handling Fetch Errors Gracefully
Always handle the case where a fetch fails. An unhandled error in a Server Component
bubbles up to the nearest error.tsx — which is fine, but you can
also handle errors more gracefully inline:
// app/products/page.tsx
export default async function ProductsPage() {
let products = [];
try {
const res = await fetch("https://api.example.com/products");
if (!res.ok) {
throw new Error(`API error: ${res.status}`);
}
products = await res.json();
} catch (error) {
// Return a fallback UI instead of crashing
return (
<div className="text-center p-12">
<p className="text-red-600">
Failed to load products. Please try again later.
</p>
</div>
);
}
return (
<ul>
{products.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}
The right approach depends on the situation:
-
Critical data (the page is useless without it): let it throw and
rely on
error.tsxto show a retry UI. - Optional data (a widget, a recommendations panel): catch the error and render a graceful fallback so the rest of the page still works.
9. TypeScript — Typing Your Fetched Data
Always type the data you fetch. It catches bugs at compile time and makes your components much easier to work with:
// types/index.ts
export type Post = {
id: number;
title: string;
body: string;
userId: number;
};
export type User = {
id: number;
name: string;
email: string;
};
// app/posts/page.tsx
import type { Post } from "@/types";
export default async function PostsPage() {
const res = await fetch("https://jsonplaceholder.typicode.com/posts");
const posts: Post[] = await res.json();
return (
<ul>
{posts.map((post) => (
// TypeScript knows post.title and post.body exist — autocomplete works!
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
If you're using Prisma or Drizzle, the types are inferred automatically from your schema — you don't need to define them manually.
10. Common Gotchas
-
Fetching in Client Components with useEffect. If you find yourself
writing
useEffect(() => { fetch(...) }, []), ask yourself if the component really needs to be a Client Component. In most cases, moving the fetch to a parent Server Component is cleaner, faster, and eliminates the loading state boilerplate. -
Not handling non-OK responses.
fetch()only throws on network errors — a 404 or 500 response does not throw. Always checkres.okorres.statusbefore callingres.json(). -
Accidental sequential fetching. Writing multiple
awaitcalls one after another is the easiest way to create a waterfall. Stop and ask: "Are these fetches independent?" If yes, usePromise.all. -
Forgetting that
cache()is per-request. React'scache()deduplicates within a single server render — it's not a persistent cache between requests. For persistent caching, use fetch with revalidation options or an external cache like Redis. - Putting secrets in fetch URLs logged to the client. Even though Server Components don't send their code to the browser, be careful with API keys in URLs — they may appear in server logs. Use environment variables and keep them in headers instead of query params when possible.
Key Takeaways
- Server Components can be
async—awaitdata directly in JSX withoutuseEffector loading state. - Query your database directly in Server Components — no API route needed.
- The extended
fetchAPI supportsno-store(always fresh),force-cache(cache forever), andrevalidate(time-based freshness). - Use
Promise.allfor independent fetches — it's always faster than sequential awaits. - Combine Suspense boundaries with per-component fetching for progressive loading of complex pages.
- Next.js deduplicates identical
fetch()calls automatically. Wrap DB queries in React'scache()for the same benefit. - Any Server Component can fetch its own data — colocate fetching with the component that needs it.
- Always check
res.ok—fetchdoesn't throw on 4xx/5xx responses.
Next up: Lesson 203 — Caching & Revalidation Strategies. You'll go deep on the
four caching layers in Next.js, understand exactly when data gets cached and when
it doesn't, and learn how to invalidate cache entries on demand using
revalidatePath and revalidateTag.