Contents
25 Chapter

Data fetching and caching

Fetching data directly with `async / await` inside Server Components, the Next.js 15 fetch cache options (`force-cache` / `no-store` / `revalidate`), route-level options, parallel fetching, and `error.tsx`.

Chapter 24 laid out the Server / Client boundary. This chapter focuses on the most powerful capability of Server Components — the way data fetching becomes simple.

The patterns in this chapter directly show how the boilerplate of client-side fetching from Chapter 10 (useEffect) and Chapter 21 (fetch and API typing) disappears. In Chapter 26 Suspense and use(), we will stack progressive rendering on top of the fetching from this chapter.

The complexity of client-side fetching #

Remember the pattern from Chapter 10 when fetching with useEffect?

the previous client-side pattern (recap from Chapter 10)
'use client';

import { useEffect, useState } from 'react';

type User = { id: number; name: string };

function UserProfile({ userId }: { userId: number }) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let cancelled = false;
    setLoading(true);

    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => { if (!cancelled) setUser(data); })
      .catch(err => { if (!cancelled) setError(err.message); })
      .finally(() => { if (!cancelled) setLoading(false); });

    return () => { cancelled = true; };
  }, [userId]);

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error}</p>;
  if (!user) return null;
  return <p>{user.name}</p>;
}

That was the standard pattern. Three pieces of state, useEffect, race-condition handling, and loading / error branches. The same boilerplate repeats every time.

In a Server Component #

The same thing in a Server Component looks like this.

src/app/users/[userId]/page.tsx
type User = { id: number; name: string };

type Props = {
  params: Promise<{ userId: string }>;
};

export default async function UserProfile({ params }: Props) {
  const { userId } = await params;
  const user: User = await fetch(`https://api.example.com/users/${userId}`)
    .then(res => res.json());

  return <p>{user.name}</p>;
}

That is the whole thing. The differences jump out.

  • No state to create (it runs once on the server and is done; the concept of state does not apply)
  • No loading state to track (the HTML only goes to the client after fetching, so “loading” simply does not exist on the client)
  • No race condition (one server execution, done)
  • Errors just throw → the nearest error.tsx catches them

This simplicity is one of the core values that Server Components unlock. You reuse the User type defined in Chapter 21 while almost nothing reaches the client.

Beyond plain fetch #

Server Components run on the server, so they can do things the client cannot.

Querying the DB directly #

fetching directly from the DB (concept example)
import { db } from '@/lib/db';

type Post = { slug: string; title: string; content: string };

type Props = {
  params: Promise<{ slug: string }>;
};

export default async function PostPage({ params }: Props) {
  const { slug } = await params;
  const post = await db.query<Post>('SELECT * FROM posts WHERE slug = $1', [slug]);

  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
    </article>
  );
}

You do not need a separate API. Client-side React absolutely cannot do this — exposing DB credentials in the browser is a security incident. In a Server Component, credentials never reach the client, so it is safe.

Reading the file system #

reading an MDX file directly
import fs from 'node:fs/promises';
import path from 'node:path';

type Props = {
  params: Promise<{ slug: string }>;
};

export default async function PostPage({ params }: Props) {
  const { slug } = await params;
  const filePath = path.join(process.cwd(), 'posts', `${slug}.mdx`);
  const content = await fs.readFile(filePath, 'utf-8');
  // ... compile MDX ...
  return <article>{/* ... */}</article>;
}

You can import Node.js modules like fs straight into a Server Component. The same code would have been a build error in a file with 'use client'. In a Server Component it just works.

Next.js’s fetch caching #

Next.js wraps fetch once more and adds automatic caching. Multiple fetches to the same URL (within the same request) only happen once for real. You can also control caching behavior at build time and at runtime.

The default — request-scoped dedup #

When multiple components in the same page fetch the same data, only one actual HTTP call happens.

the same fetch called multiple times
async function getUser(id: number) {
  return fetch(`https://api.example.com/users/${id}`).then(r => r.json());
}

export default async function Page() {
  const userA = await getUser(1);  // actual call
  const userB = await getUser(1);  // returned from cache (automatic)
  // ...
  return null;
}

You called getUser twice, but only one HTTP request goes out. Next.js deduplicates identical fetches within a single page render.

Cache options — cache and next.revalidate #

You control caching behavior through the second argument to fetch.

cache option examples
// 1. Permanent cache (static data that barely changes)
fetch(url, { cache: 'force-cache' });

// 2. No caching (always fetch fresh)
fetch(url, { cache: 'no-store' });

// 3. Revalidate every N seconds (data that changes often)
fetch(url, { next: { revalidate: 60 } });

// 4. Tag-based revalidation (manual invalidation possible)
fetch(url, { next: { tags: ['posts'] } });

When to use each.

  • force-cache — fetched once at build time and cached permanently. Data that hardly changes (static page info, category lists, etc.)
  • no-store — always fetched fresh. Per-user data, real-time-sensitive info
  • revalidate: 60 — cached for 60 seconds, then refreshed in the background on the first request after that. “Mostly static but changes occasionally” — like a blog post list
  • tags — invalidated manually from code with revalidateTag('posts'). When posts are added or removed

From Next.js 15 the default switched to no-store (no caching). Articles written for older versions may still say force-cache is the default, but on the current version it is better to explicitly set the cache option.

Note
Caching is one of Next.js’s most powerful — and most confusing — features. This chapter covers only the basics; you should decide a full caching strategy against your actual project requirements. A safe approach is to start with cache: 'no-store' to get correct behavior, then layer caching in when you need performance.

Route-level options — revalidate, dynamic #

You can also control behavior at the page level.

src/app/posts/page.tsx
export const revalidate = 60;  // regenerate this entire page every 60 seconds

export default async function PostsPage() {
  const posts = await fetch('https://api.example.com/posts').then(r => r.json());
  return <ul>{/* ... */}</ul>;
}
the dynamic option
export const dynamic = 'force-dynamic';  // re-render on every request (no caching)

This expresses the cache policy of the whole page in one line. Use dynamic for pages where data changes often, and revalidate for pages that change rarely.

Parallel fetching — Promise.all #

When you need multiple pieces of data, sequential awaits create a waterfall.

🐢 sequential fetching (slow)
const user = await getUser(id);     // 100ms
const posts = await getPosts(id);   // another 100ms. 200ms total

If the data are not dependent on each other, it is better to fetch them in parallel.

🚀 parallel fetching
const [user, posts] = await Promise.all([
  getUser(id),
  getPosts(id),
]);
// both start at the same time → 100ms (gated by the slower one)

Wrapping with Promise.all starts the two requests at the same time. It is a common pattern in Server Components.

Even better — split per component #

If each piece of data is fetched directly by the component that uses it, the combination with Next.js’s automatic dedup gives you very natural parallelism.

src/app/users/[id]/page.tsx
type Props = {
  params: Promise<{ id: string }>;
};

export default async function UserPage({ params }: Props) {
  const { id } = await params;
  return (
    <div>
      <UserHeader userId={id} />
      <UserPosts userId={id} />
    </div>
  );
}

async function UserHeader({ userId }: { userId: string }) {
  const user = await getUser(userId);
  return <h1>{user.name}</h1>;
}

async function UserPosts({ userId }: { userId: string }) {
  const posts = await getPosts(userId);
  return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}

Because a Server Component can be an async function itself, each child can fetch its own data as shown above. React runs them in parallel.

Another benefit of this pattern is that each part only depends on its own data. UserHeader does not have to wait if UserPosts’s data is slow, and when combined with Suspense in Chapter 26 you can stream the ready parts to the screen first.

Error handling — error.tsx #

When fetching fails in a Server Component, just throw. Next.js finds the nearest error.tsx and shows it.

src/app/posts/error.tsx
'use client';

type Props = {
  error: Error & { digest?: string };
  reset: () => void;
};

export default function ErrorBoundary({ error, reset }: Props) {
  return (
    <div style={{ padding: '24px' }}>
      <h2>Something went wrong.</h2>
      <p>{error.message}</p>
      <button onClick={reset}>Try again</button>
    </div>
  );
}

With this file in place, any error from /posts or pages below it shows this screen. 'use client' is required because reset is a client-side action (the onClick event, etc.).

Try it — a small site against the GitHub API #

Let’s evolve the site from Chapter 23 into something that fetches real data. We will use GitHub’s public API (rate-limited without auth, but plenty for learning).

src/app/repos/[owner]/[repo]/page.tsx:

src/app/repos/[owner]/[repo]/page.tsx
type Repo = {
  full_name: string;
  description: string | null;
  stargazers_count: number;
  forks_count: number;
  watchers_count: number;
  language: string | null;
  html_url: string;
};

type Props = {
  params: Promise<{ owner: string; repo: string }>;
};

export default async function RepoPage({ params }: Props) {
  const { owner, repo } = await params;

  const data: Repo = await fetch(
    `https://api.github.com/repos/${owner}/${repo}`,
    { next: { revalidate: 300 } }  // 5 minute cache
  ).then(res => {
    if (!res.ok) throw new Error('Repo not found');
    return res.json();
  });

  return (
    <div style={{ padding: '24px' }}>
      <h1>{data.full_name}</h1>
      <p>{data.description}</p>
      <ul>
        <li> {data.stargazers_count.toLocaleString()}</li>
        <li>🍴 {data.forks_count.toLocaleString()}</li>
        <li>👁 {data.watchers_count.toLocaleString()}</li>
        <li>Main language: {data.language ?? 'unknown'}</li>
      </ul>
      <a href={data.html_url} target="_blank" rel="noopener">View on GitHub</a>
    </div>
  );
}

src/app/repos/error.tsx:

src/app/repos/error.tsx
'use client';

type Props = {
  error: Error & { digest?: string };
  reset: () => void;
};

export default function ErrorBoundary({ error, reset }: Props) {
  return (
    <div style={{ padding: '24px' }}>
      <h2>Could not find that repository</h2>
      <p style={{ color: '#888', fontSize: '14px' }}>{error.message}</p>
      <button onClick={reset}>Try again</button>
    </div>
  );
}

Add links in src/app/page.tsx:

src/app/page.tsx (updated)
import Link from 'next/link';

export default function HomePage() {
  return (
    <div style={{ padding: '24px' }}>
      <h1>Browse GitHub repos</h1>
      <ul>
        <li><Link href="/repos/facebook/react">facebook/react</Link></li>
        <li><Link href="/repos/vercel/next.js">vercel/next.js</Link></li>
        <li><Link href="/repos/curtisdev/this-does-not-exist">a repo that does not exist</Link></li>
      </ul>
    </div>
  );
}

Click each link.

  • Real repo: info fetched from GitHub appears on screen
  • Missing repo: error.tsx intercepts and shows the error screen
  • Return within 5 minutes: the cached result loads instantly

Everything that just happened was on the server. Almost no JavaScript reaches the browser. From the user’s point of view, the response is indistinguishable from plain static HTML — fast. From the developer’s point of view, it is just await fetch(...) on one line.

Exercises #

  1. Confirm dedup directly. Make the RepoPage above call the same fetch twice. Time it with console.time / console.timeEnd, then switch the cache option to cache: 'no-store' and compare whether the same fetch actually fires twice. Also watch how many fetches show up in the dev server console.
  2. Experiment with revalidate. Change revalidate: 300 in repos/[owner]/[repo]/page.tsx to revalidate: 5, then refresh the page and confirm that GitHub’s X-RateLimit-Remaining header only decreases on 5-second boundaries. After that, add export const dynamic = 'force-dynamic' to the page and watch fetches happen on every request.
  3. Parallel fetching vs per-component split. Write a page that fetches two users’ info two ways: (a) wrapped in Promise.all and (b) two child Server Components each fetching on their own. Compare the request timing in the network tab. Explain why both finish in roughly the same time, using the chapter content (automatic dedup + parallel child execution).

In one line: Data fetching in a Server Component is one line — async function + await fetch(...) — and the loading / error / race-condition boilerplate disappears. Next.js’s fetch adds automatic in-request dedup and fine-grained cache control with cache / next.revalidate / next.tags (explicit settings are safer from Next.js 15). At the route level you have export const revalidate and export const dynamic. For independent data, parallel-fetch with Promise.all or split per child component, and errors propagate by throwing → error.tsx catches them.

Next chapter #

In Chapter 26 Suspense and use(), we tackle the leftover issue: the pages we built in this chapter show a blank screen until every piece of data arrives. We learn how <Suspense> and loading.tsx let you stream the ready parts to the screen first, and how the React 19 stable use() hook lets the Client unwrap a Promise that the Server created.

X