Data Fetching in Next.js ๐Ÿ“ก

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

Most of the patterns here apply to Server Components, but client-side fetching is also covered for interactive, user-driven data needs.

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

Parallel fetching is one of the highest-impact performance optimizations you can make in a data-heavy Server Component.

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

Sequential requests increase total load time โ€” only use this pattern when a genuine dependency exists between fetches.

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.

Component A: fetch("/api/user")
Component B: fetch("/api/user")
Component C: fetch("/api/user")

Note

Request memoization only applies within a single request lifecycle โ€” it resets between separate incoming requests.

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.

CacheScopePersists Across
Request Memoizationsingle renderโŒ
Data Cacheserver-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 โš™๏ธ

OptionPurpose
cache: "force-cache"cache indefinitely (default)
cache: "no-store"never cache, always fetch fresh
next.revalidatetime-based revalidation interval
next.tagstags 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

Throwing inside a Server Component is automatically caught by the nearest error.tsx boundary.

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

Direct database access MUST stay in Server Components only โ€” never expose database clients to Client Components.

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.

Frequently Asked Questions ๐Ÿ’ฌ

Question

Is fetch() cached by default in the App Router?

Answer

Yes โ€” Server Component fetch() calls are cached indefinitely unless you specify cache: "no-store" or a revalidate value.

Question

Can I fetch data in a Client Component the same way as a Server Component?

Answer

Not directly โ€” Client Components can't be async, so they typically use useEffect or a library like SWR instead.

Question

What's the difference between revalidatePath and revalidateTag?

Answer

revalidatePath targets a specific route, while revalidateTag invalidates all cached fetches sharing that tag, across any route.

Summary ๐Ÿ“Œ

Summary

Mastering Next.js data fetching means knowing not just how to fetch data, but where, when, and how often โ€” turning caching into a performance advantage rather than a source of stale bugs.