Introduction ๐
Data fetching is at the heart of most Next.js applications. The App Router extends the native fetch API with powerful caching, deduplication, and revalidation features, and gives Server Components the ability to fetch data directly without a client-side round trip. This tutorial covers every layer of data fetching, from simple requests to advanced caching strategies.
Information
What is Data Fetching? ๐ง
Data fetching is the process of retrieving data โ from a database, a REST API, a CMS, or any other source โ to render into your UI. In Next.js, where and when that fetch happens has a big impact on performance, freshness, and SEO.
- Fetching on the server, before the page is sent to the browser.
- Fetching on the client, after the page has loaded and hydrated.
Data Fetching in Next.js ๐
Next.js encourages fetching data as close to where it's used as possible, directly inside Server Components, using an extended version of the native fetch() function that integrates with the framework's caching system.
app/posts/page.tsx
export default async function Posts() {
const res = await fetch("https://api.example.com/posts");
const posts = await res.json();
return <List type="unordered">{posts.map((p) => <List.Item key={p.id}>{p.title}</List.Item>)}</List>;
}Fetch API ๐
Next.js extends the Web fetch() API with a next options object, letting you configure caching and revalidation behavior per request, directly alongside the fetch call itself.
lib/getData.ts
export async function getData() {
const res = await fetch("https://api.example.com/data", {
next: { revalidate: 60, tags: ["data"] },
});
return res.json();
}Server-Side Data Fetching ๐ฅ๏ธ
Fetching data in a Server Component happens before any HTML is sent to the browser. This means no loading spinners are needed for the initial render, and sensitive logic like API keys never reaches the client.
app/dashboard/page.tsx
export default async function Dashboard() {
const stats = await fetch("https://api.example.com/stats", { cache: "no-store" }).then((r) => r.json());
return <p>Active users: {stats.activeUsers}</p>;
}Client-Side Data Fetching ๐ป
Client-side fetching is appropriate for data that's user-specific, changes frequently after load, or depends on client-only state. It's typically done with useEffect or a library like SWR.
components/Notifications.tsx
"use client";
import useSWR from "swr";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
export default function Notifications() {
const { data, isLoading } = useSWR("/api/notifications", fetcher, { refreshInterval: 5000 });
if (isLoading) return <p>Loadingโฆ</p>;
return <p>{data.count} new notifications</p>;
}Static Data Fetching ๐ง
By default, fetch() requests in Server Components are cached indefinitely, making the data static โ fetched once at build time and reused across all requests until manually revalidated.
app/about/page.tsx
export default async function About() {
const data = await fetch("https://api.example.com/company"); // cached by default
const company = await data.json();
return <h1>{company.name}</h1>;
}Dynamic Data Fetching ๐
Setting cache: "no-store" opts a specific fetch() call out of caching entirely, ensuring fresh data on every request โ and typically making the whole route dynamic as a result.
app/live/page.tsx
export default async function Live() {
const res = await fetch("https://api.example.com/live", { cache: "no-store" });
const data = await res.json();
return <p>{data.value}</p>;
}Parallel Data Fetching โก
Parallel fetching kicks off multiple independent requests at the same time using Promise.all, rather than await-ing them one after another โ significantly reducing total wait time.
app/profile/page.tsx
export default async function Profile() {
const [user, posts] = await Promise.all([
fetch("https://api.example.com/user").then((r) => r.json()),
fetch("https://api.example.com/posts").then((r) => r.json()),
]);
return (
<div>
<h1>{user.name}</h1>
<p>{posts.length} posts</p>
</div>
);
}Tip
Sequential Data Fetching โญ๏ธ
Sequential fetching happens when one request must wait for another to complete first, typically because the second request depends on data from the first.
app/user/[id]/page.tsx
export default async function User({ params }: { params: { id: string } }) {
const user = await fetch(`https://api.example.com/users/${params.id}`).then((r) => r.json());
const orders = await fetch(`https://api.example.com/orders?userId=${user.id}`).then((r) => r.json());
return <p>{user.name} has {orders.length} orders</p>;
}Caution
Dependent Data Fetching ๐
Dependent fetching is a specific case of sequential fetching where the second request's parameters literally come from the first response, such as fetching a user before fetching that user's permissions.
app/settings/page.tsx
export default async function Settings() {
const session = await getSession();
const permissions = await fetch(`https://api.example.com/permissions/${session.userId}`).then((r) => r.json());
return <p>Role: {permissions.role}</p>;
}Streaming Data ๐
Wrapping a slow data-fetching component in <Suspense> allows the rest of the page to render immediately, streaming in the slower content once it's ready.
app/dashboard/page.tsx
import { Suspense } from "react";
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<p>Loading revenueโฆ</p>}>
<Revenue />
</Suspense>
</div>
);
}Request Memoization ๐งฎ
Within a single render pass, Next.js automatically deduplicates identical fetch() calls (same URL and options), so calling the same fetch in multiple components only triggers one actual network request.
Note
Data Cache ๐๏ธ
The Data Cache persists fetch() results across requests and even across deployments, unlike request memoization which resets per-request. This is what powers static rendering and ISR.
| Cache | Scope | Persists Across |
|---|---|---|
| Request Memoization | single render | โ |
| Data Cache | server-wide | โ requests & deploys |
Request Deduplication ๐
Deduplication ensures that even if getUser() is called from five different components in the same tree, only one network request is made โ as long as the underlying fetch() call is identical.
lib/getUser.ts
export async function getUser(id: string) {
const res = await fetch(`https://api.example.com/users/${id}`);
return res.json();
}Revalidation ๐
Revalidation refreshes cached data without requiring a full rebuild. Next.js supports both automatic, time-based revalidation and manual, on-demand revalidation triggered from your own code.
- Time-based โ data refreshes automatically after a set interval.
- On-demand โ data refreshes immediately when triggered by a mutation.
Time-Based Revalidation โฐ
Passing a revalidate value (in seconds) to fetch(), or exporting it from a route segment, tells Next.js to treat cached data as stale after that interval and regenerate it in the background.
app/products/page.tsx
export default async function Products() {
const products = await fetch("https://api.example.com/products", {
next: { revalidate: 3600 },
}).then((r) => r.json());
return <p>{products.length} products</p>;
}On-Demand Revalidation ๐ฏ
On-demand revalidation lets you invalidate cached data immediately, right after a mutation happens โ for example, clearing a product page's cache the moment its price is updated.
app/actions.ts
"use server";
import { revalidatePath } from "next/cache";
export async function updateProduct(id: string) {
await db.products.update(id);
revalidatePath(`/products/${id}`);
}revalidatePath ๐ฃ๏ธ
revalidatePath clears the cache for a specific route (and its data), forcing Next.js to regenerate that path on the next visit.
app/actions.ts
"use server";
import { revalidatePath } from "next/cache";
export async function publishPost() {
await createPost();
revalidatePath("/blog");
}revalidateTag ๐ท๏ธ
revalidateTag invalidates every cached fetch() call sharing a given tag, regardless of which route it was fetched from โ ideal for data used across multiple pages.
app/actions.ts
"use server";
import { revalidateTag } from "next/cache";
export async function updateInventory() {
await db.inventory.update();
revalidateTag("inventory");
}Cache Tags ๐ท๏ธ
Cache tags are labels attached to a fetch() call via next: { tags: [...] }, allowing many separate requests across different routes to be invalidated together with a single revalidateTag call.
lib/getInventory.ts
export async function getInventory() {
const res = await fetch("https://api.example.com/inventory", {
next: { tags: ["inventory"] },
});
return res.json();
}unstable_cache ๐งช
unstable_cache extends caching benefits to non-fetch data sources, like direct database queries, letting you wrap any async function with the same tag-based revalidation model.
lib/getPosts.ts
import { unstable_cache } from "next/cache";
export const getPosts = unstable_cache(
async () => db.posts.findMany(),
["posts"],
{ tags: ["posts"], revalidate: 3600 }
);cache() โป๏ธ
React's cache() function memoizes the result of any function โ not just fetch() โ within a single render pass, which is useful for deduplicating direct database calls across components.
lib/getUser.ts
import { cache } from "react";
export const getUser = cache(async (id: string) => {
return db.users.findUnique({ where: { id } });
});Fetch Options โ๏ธ
| Option | Purpose |
|---|---|
| cache: "force-cache" | cache indefinitely (default) |
| cache: "no-store" | never cache, always fetch fresh |
| next.revalidate | time-based revalidation interval |
| next.tags | tags for on-demand revalidation |
Error Handling โ ๏ธ
Wrapping data fetching in a try/catch block, combined with an error.tsx file for uncaught errors, gives you full control over how fetch failures are surfaced to the user.
app/products/page.tsx
export default async function Products() {
const res = await fetch("https://api.example.com/products");
if (!res.ok) {
throw new Error("Failed to fetch products");
}
const products = await res.json();
return <p>{products.length} products</p>;
}Tip
Loading States โณ
A loading.tsx file automatically wraps a route in a <Suspense> boundary, showing instant feedback while async data fetching completes.
app/products/loading.tsx
export default function Loading() {
return <p>Loading productsโฆ</p>;
}Empty States ๐ญ
Always handle the case where a fetch succeeds but returns no data โ an empty array or null result should render a friendly message rather than a blank or broken UI.
app/orders/page.tsx
export default async function Orders() {
const orders = await getOrders();
if (orders.length === 0) {
return <p>You haven't placed any orders yet.</p>;
}
return <List type="unordered">{orders.map((o) => <List.Item key={o.id}>{o.id}</List.Item>)}</List>;
}Authentication Requests ๐
Authenticated fetches typically attach a token or session cookie to the request headers. In Server Components, this often means reading cookies via cookies() and forwarding them manually.
lib/getProfile.ts
import { cookies } from "next/headers";
export async function getProfile() {
const token = cookies().get("session")?.value;
const res = await fetch("https://api.example.com/profile", {
headers: { Authorization: `Bearer ${token}` },
});
return res.json();
}External APIs ๐
Fetching from third-party APIs works the same way as any other fetch() call, but it's good practice to centralize API keys and base URLs in environment variables rather than hardcoding them.
lib/weather.ts
export async function getWeather(city: string) {
const res = await fetch(`https://api.weather.com/v1/${city}?key=${process.env.WEATHER_API_KEY}`);
return res.json();
}Database Queries ๐๏ธ
Server Components can query a database directly using an ORM like Prisma or Drizzle, skipping the need for a REST or GraphQL API layer entirely.
app/users/page.tsx
import { prisma } from "@/lib/prisma";
export default async function Users() {
const users = await prisma.user.findMany();
return <List type="unordered">{users.map((u) => <List.Item key={u.id}>{u.name}</List.Item>)}</List>;
}Important
Data Fetching Patterns ๐๏ธ
Each component fetches exactly the data it needs, right where it's used โ simple and maintainable, relying on automatic request deduplication to avoid redundant network calls.
A preload() helper kicks off a fetch before it's actually needed, reducing waterfalls when a component tree has known future data dependencies.
Data-fetching logic is separated into dedicated Server Components that pass fetched data down as props to presentation-only components.
Performance Optimization ๐
- Fetch data in parallel with Promise.all whenever requests are independent.
- Use <Suspense> to stream slower fetches instead of blocking the whole page.
- Tag related fetch() calls so a single revalidateTag can invalidate them together.
- Avoid unnecessary cache: "no-store" calls when data doesn't truly need to be real-time.
Best Practices โ
- Fetch data as close as possible to where it's used, relying on deduplication rather than prop-drilling.
- Prefer revalidateTag over broad revalidatePath calls for more precise cache invalidation.
- Wrap slow, non-critical data in <Suspense> so it doesn't block the rest of the page.
- Use unstable_cache or React's cache() for non-fetch data sources like ORMs.
Common Mistakes โ ๏ธ
- Awaiting independent fetches sequentially instead of using Promise.all.
- Forgetting that cache: "no-store" opts the entire route into dynamic rendering.
- Exposing database clients or secrets to Client Components.
- Not handling empty or error states, leading to broken or blank UI.