ProgrUmar Logo
Module 2: React Server Components & Data Fetching

Fetching Data in Server Components

Duration: 20 mins

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.tsx to 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 check res.ok or res.status before calling res.json().
  • Accidental sequential fetching. Writing multiple await calls one after another is the easiest way to create a waterfall. Stop and ask: "Are these fetches independent?" If yes, use Promise.all.
  • Forgetting that cache() is per-request. React's cache() 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 asyncawait data directly in JSX without useEffect or loading state.
  • Query your database directly in Server Components — no API route needed.
  • The extended fetch API supports no-store (always fresh), force-cache (cache forever), and revalidate (time-based freshness).
  • Use Promise.all for 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's cache() for the same benefit.
  • Any Server Component can fetch its own data — colocate fetching with the component that needs it.
  • Always check res.okfetch doesn'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.

Chat with us