R
Rishtaara
Next.js: Zero to Full-Stack
Lesson 20 of 42Article15 min

Fetching Data with Server Components

In the App Router, page.tsx and most components are Server Components by default. They can be async: await a database or fetch, then return JSX. The browser never sees your query — only the finished HTML (plus the RSC payload).

async Server Components — fetch where the HTML is built

In the App Router, page.tsx and most components are Server Components by default. They can be async: await a database or fetch, then return JSX. The browser never sees your query — only the finished HTML (plus the RSC payload).

Real-life example: A Server Component is a chef who cooks in the kitchen and brings a plated dish to the table. Guests do not walk into the kitchen and grab raw ingredients.

app/dashboard/page.tsx
import { sql } from "@vercel/postgres";

async function getDashboardStats() {
  const { rows } = await sql`
    SELECT
      COUNT(*)::int AS total,
      COALESCE(SUM(amount), 0)::int AS revenue
    FROM invoices
    WHERE status = 'paid'
  `;
  return rows[0];
}

export default async function DashboardPage() {
  const stats = await getDashboardStats();

  return (
    <main>
      <h1>Dashboard</h1>
      <p>Paid invoices: {stats.total}</p>
      <p>Revenue: ₹{stats.revenue}</p>
    </main>
  );
}

fetch in Next.js — caching notes

Next.js extends fetch. By default (depending on version and route settings), results can be cached and reused across requests. That is great for a blog, wrong for a live bank balance.

You control the behavior with options and route segment config.

  • fetch(url, { cache: 'force-cache' }) — prefer cached (static-friendly)
  • fetch(url, { cache: 'no-store' }) — always fresh (dynamic)
  • fetch(url, { next: { revalidate: 60 } }) — ISR-style: refresh at most every 60s
  • fetch(url, { next: { tags: ['invoices'] } }) — tag for on-demand revalidation
Prefer Server Components + await for first paint. Reach for client fetch only when the data truly depends on browser-only state (see later lessons).
Cached vs fresh fetch
// Good for a public course catalog (can be slightly stale)
const courses = await fetch("https://api.example.com/courses", {
  next: { revalidate: 3600, tags: ["courses"] },
}).then((r) => r.json());

// Good for the signed-in user's notifications
const notes = await fetch("https://api.example.com/notifications", {
  cache: "no-store",
  headers: { Authorization: `Bearer ${token}` },
}).then((r) => r.json());

Dashboard data example — parallel awaits

If two queries do not depend on each other, start them together with Promise.all so the page waits once, not twice in a row.

Real-life example: Asking two assistants to get coffee and printouts at the same time beats sending one, waiting, then sending the other.

Parallel data loading
async function getRecentInvoices() {
  const res = await fetch("https://api.example.com/invoices?limit=5", {
    next: { tags: ["invoices"] },
  });
  return res.json();
}

async function getCustomers() {
  const res = await fetch("https://api.example.com/customers", {
    next: { revalidate: 300 },
  });
  return res.json();
}

export default async function DashboardPage() {
  const [invoices, customers] = await Promise.all([
    getRecentInvoices(),
    getCustomers(),
  ]);

  return (
    <section>
      <h2>Recent invoices ({invoices.length})</h2>
      <p>Customers on file: {customers.length}</p>
    </section>
  );
}