Modern React + Next.js #4 Data Fetching and Caching
Last time we covered the difference between Server and Client Components and the boundary between them. This time we focus on Server Components’ most powerful capability — how data fetching becomes simple.
The complexity of client-side fetching #
Remember the pattern from #1, fetching data inside useEffect?
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(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>;
return <p>{user.name}</p>;
}This was the standard pattern. Three pieces of state, useEffect, race condition handling, branching for loading/error — the same boilerplate repeats every time.
The same thing in a Server Component #
The same task as a Server Component:
export default async function UserProfile({ params }) {
const { userId } = await params;
const user = await fetch(`https://api.example.com/users/${userId}`)
.then(res => res.json());
return <p>{user.name}</p>;
}That’s all. The differences are obvious at a glance.
- No state to manage (the server runs once and is done — there’s no concept of “state”)
- No need to think about loading (the HTML doesn’t go to the client until the fetch completes, so the client never sees a “loading” state)
- No race conditions (server runs once, done)
- Errors are just thrown → the nearest
error.jscatches them
This simplicity is one of the core values Server Components deliver.
Beyond direct fetch #
Since Server Components run on the server, they can do things the client can’t.
Direct DB queries #
import { db } from '@/lib/db';
export default async function PostPage({ params }) {
const { slug } = await params;
const post = await db.query('SELECT * FROM posts WHERE slug = $1', [slug]);
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
</article>
);
}You don’t need to set up a separate API. This is something client-side React absolutely cannot do — exposing DB credentials in the browser is a security disaster. With Server Components, those credentials never leave the server, so it’s safe.
Reading the file system #
import fs from 'fs';
import path from 'path';
export default async function PostPage({ params }) {
const { slug } = await params;
const filePath = path.join(process.cwd(), 'posts', `${slug}.mdx`);
const content = fs.readFileSync(filePath, 'utf-8');
// ... compile MDX ...
}This blog (schoolofweb.net) works exactly this way. MDX files in the posts/ folder are read directly in a Server Component, compiled, and rendered.
Next.js’s fetch caching #
Next.js wraps fetch and adds automatic caching. If you fetch the same URL multiple times (within the same request) only one real call happens. You can also control caching behavior at build time and runtime.
Default behavior — request-scoped dedup #
If multiple components within the same page fetch the same data, only one actual call is made.
async function getUser(id) {
return fetch(`/api/users/${id}`).then(r => r.json());
}
export default async function Page() {
const userA = await getUser(1); // real call
const userB = await getUser(1); // served from cache (automatic)
// ...
}We called getUser twice, but only one HTTP request was made. Next.js deduplicates identical fetches during a single page render.
Cache options — cache and next.revalidate
#
You can control caching with fetch’s second argument.
// 1. Permanent cache (static data that almost never changes)
fetch(url, { cache: 'force-cache' });
// 2. Don't cache (re-fetch every time)
fetch(url, { cache: 'no-store' });
// 3. Revalidate every N seconds (data that changes occasionally)
fetch(url, { next: { revalidate: 60 } });
// 4. Tag-based revalidation (manual invalidation)
fetch(url, { next: { tags: ['posts'] } });When to use each:
force-cache— fetched once at build time and cached forever. Use for data that almost never changes (static page info, category lists, etc.)no-store— always fresh. Use for per-user data or info where freshness mattersrevalidate: 60— cached for 60 seconds, then refreshed in the background on the first request after that. Use for “almost static but occasionally changes” data like a blog post listtags— callrevalidateTag('posts')from your code to invalidate manually. Use when posts are created or deleted
The default changed in Next.js 15 to no-store (don’t cache). Older guides may say force-cache is the default, but with current Next.js it’s better to set the cache option explicitly.
cache: 'no-store', get things working correctly, and add caching only when you need performance.Route-level options — revalidate, dynamic
#
You can also control behavior at the page level.
export const revalidate = 60; // regenerate this whole page every 60 seconds
export default async function PostsPage() {
const posts = await fetch('https://api.example.com/posts').then(r => r.json());
return /* ... */;
}export const dynamic = 'force-dynamic'; // re-render on every request (no caching)These express the page’s caching policy in a single line. Use dynamic for pages where data changes constantly, and revalidate for pages that change rarely.
Parallel fetching — Promise.all #
When you need multiple pieces of data, sequential awaits create a waterfall.
const user = await getUser(id); // 100 ms
const posts = await getPosts(id); // another 100 ms — total 200 msIf the data items don’t depend on each other, fetch them in parallel.
const [user, posts] = await Promise.all([
getUser(id),
getPosts(id),
]);
// both start at the same time → 100 ms (capped by the slower one)Promise.all kicks off both requests simultaneously. It’s a common pattern in Server Components.
Even better — split per component #
If each piece of data is fetched by the component that uses it, the combination with Next.js’s automatic dedup produces parallelism almost for free.
export default async function UserPage({ params }) {
const { id } = await params;
return (
<div>
<UserHeader userId={id} />
<UserPosts userId={id} />
</div>
);
}
async function UserHeader({ userId }) {
const user = await getUser(userId);
return <h1>{user.name}</h1>;
}
async function UserPosts({ userId }) {
const posts = await getPosts(userId);
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}Server Components can themselves be async functions, so descendant components can also fetch their own data, as shown above. React runs them in parallel.
Another benefit of this pattern is that each part depends only on its own data. UserHeader can keep going regardless of when UserPosts’ data arrives, and combined with Suspense (covered in #5), it enables streaming — the parts that finish quickly appear on screen first.
Error handling — error.js #
When fetching fails in a Server Component, just throw. Next.js finds the nearest error.js and renders it.
'use client';
export default function ErrorBoundary({ error, reset }) {
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, errors that occur on /posts or any subpage render this screen. 'use client' is required because reset is a client-side action.
Hands-on — a small site backed by the GitHub API #
Let’s evolve the site from the last post to fetch real data. We’ll use GitHub’s public API (rate-limited per hour without authentication, but more than enough for learning).
src/app/repos/[owner]/[repo]/page.js:
export default async function RepoPage({ params }) {
const { owner, repo } = await params;
const data = await fetch(
`https://api.github.com/repos/${owner}/${repo}`,
{ next: { revalidate: 300 } } // cached for 5 minutes
).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>Primary language: {data.language}</li>
</ul>
<a href={data.html_url} target="_blank" rel="noopener">View on GitHub</a>
</div>
);
}src/app/repos/error.js:
'use client';
export default function ErrorBoundary({ error, reset }) {
return (
<div style={{ padding: '24px' }}>
<h2>Repository not found</h2>
<p style={{ color: '#888', fontSize: '14px' }}>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
}Add links to src/app/page.js:
import Link from 'next/link';
export default function HomePage() {
return (
<div style={{ padding: '24px' }}>
<h1>View GitHub repositories</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 doesn't exist</Link></li>
</ul>
</div>
);
}Click each link.
- A real repo: the info fetched from GitHub is shown
- A non-existent repo:
error.jscatches the error and the error screen shows - Revisit within 5 minutes: the cached result appears instantly
Everything that just happened occurred on the server. Almost no JavaScript ships to the browser. From a user’s perspective it’s a fast response indistinguishable from plain static HTML, and from a developer’s perspective the code is just await fetch(...).
Wrap-up #
In this post we covered data fetching in Server Components.
async functioncomponents +await fetch(...)make the client-fetching boilerplate disappear- Direct access to server resources like DB / file system / env vars
- Next.js’s
fetchadds automatic dedup + cache options (cache,next.revalidate,tags) - Route-level options (
export const revalidate,export const dynamic) - Independent data parallelized via
Promise.allor per-component splitting - Errors throw → caught by
error.js
In every page so far, the screen waited until all data arrived. If one piece was slow, the fast pieces had to wait too. In the next post, “Modern React + Next.js #5 Loading with Suspense and use(),” we’ll cover streaming — showing parts as they become ready, Suspense, loading.js, and the newly arrived use() hook.