Fetching Data in Server Components
Patterns for fetching data in Next.js Server Components: direct database access, fetch with caching, parallel requests, and how React deduplicates ident...
Server Components changed the way data fetching works in Next.js. Before the App Router, you had getServerSideProps and getStaticProps sitting outside your component tree, collecting data and passing it down. Now, the component itself is async. It fetches its own data. This sounds like a small shift but it changes a lot about how you structure an app.
Async Components, Right in the Tree
Any Server Component can be async. You await data inside the component body and render it. No hooks, no loading state, no useEffect. The data is there when the component runs.
// app/notebook/[section]/[slug]/page.tsx
export default async function NotePage({
params,
}: {
params: Promise<{ section: string; slug: string }>
}) {
const { section, slug } = await params
const note = await getNote(section, slug)
return (
<article>
<h1>{note.title}</h1>
<div dangerouslySetInnerHTML={{ __html: note.content }} />
</article>
)
}
This is the baseline pattern. Await params, await data, render.
Direct Database Access (No API Layer Required)
The most useful thing about Server Components is that they run on the server. That means you can talk directly to your database, read from the filesystem, or call internal services without exposing anything to the client. There is no need for an intermediate API route.
// lib/db.ts
import { db } from '@/lib/database'
export async function getUserById(id: string) {
return db.query('SELECT * FROM users WHERE id = ?', [id])
}
// app/profile/[id]/page.tsx
import { getUserById } from '@/lib/db'
export default async function ProfilePage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const user = await getUserById(id)
if (!user) notFound()
return <ProfileCard user={user} />
}
No fetch, no serialisation, no API key leaking into the browser bundle. The database call happens at build time (or request time, depending on your caching config) and the result is rendered server-side.
fetch() with Next.js Cache Options
When you do need to call an external API or a service over HTTP, Next.js extends the native fetch API with caching options. These map directly to the old static and server-side rendering modes.
// Cached indefinitely (like getStaticProps)
const data = await fetch('https://api.example.com/posts', {
cache: 'force-cache',
})
// Never cached (like getServerSideProps)
const live = await fetch('https://api.example.com/live-score', {
cache: 'no-store',
})
// Revalidate every 60 seconds (like ISR)
const revalidated = await fetch('https://api.example.com/products', {
next: { revalidate: 60 },
})
The next object is a Next.js extension to the standard RequestInit. Setting revalidate puts the response in the cache with a time-to-live. When the TTL expires, the next request rebuilds it in the background.
You can also tag a fetch so you can invalidate it precisely:
const posts = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] },
})
Then from a Server Action or route handler:
import { revalidateTag } from 'next/cache'
revalidateTag('posts') // bust only the tagged entries
Parallel Fetching with Promise.all
Async components run sequentially by default. If you write two await calls one after another, the second doesn't start until the first resolves. For independent data, that's wasted time.
// Sequential — second waits for first
const user = await getUser(id)
const posts = await getUserPosts(id)
Use Promise.all to fire them in parallel:
// Parallel — both start at the same time
const [user, posts] = await Promise.all([
getUser(id),
getUserPosts(id),
])
On a page with several independent data sources, this can cut the total fetch time significantly. The rule is simple: if two requests don't depend on each other's results, run them together.
React's Fetch Deduplication
There is one subtlety worth knowing: React deduplicates identical fetch requests made during the same render. If two components in the tree both call fetch('https://api.example.com/config') with the same URL and options, the request goes out once. Both components get the same response.
This means you can write data-fetching functions without worrying about passing data through props just to avoid double-fetching. Each component can call what it needs. React handles the deduplication.
// Both of these trigger one network request
async function Header() {
const config = await fetch('/api/config').then(r => r.json())
return <nav style={{ color: config.brandColor }}>{/* ... */}</nav>
}
async function Footer() {
const config = await fetch('/api/config').then(r => r.json())
return <footer style={{ background: config.footerBg }}>{/* ... */}</footer>
}
Note: deduplication applies to fetch calls with matching URLs and options. If you're using a database client directly, you don't get this automatically. You'd use React's cache() function to wrap the call:
import { cache } from 'react'
import { db } from '@/lib/database'
export const getConfig = cache(async () => {
return db.from('config').select('*').single()
})
Now any component that calls getConfig() within the same request gets the same Promise, and the database is only hit once.
Revalidation on Demand
Beyond time-based revalidation, Next.js gives you revalidatePath and revalidateTag for on-demand cache busting. Call them from Server Actions or route handlers when content changes.
import { revalidatePath, revalidateTag } from 'next/cache'
// Revalidate a specific page
revalidatePath('/blog/my-post')
// Revalidate everything tagged with 'posts'
revalidateTag('posts')
A typical pattern: a content management webhook hits a route handler, the handler calls revalidateTag, and the next visitor gets fresh data without a full rebuild.
What to Reach For
For most pages: make the component async, fetch data directly. If the source is a database, call it without an API layer. If it's external HTTP, use fetch with appropriate cache settings. For independent data on the same page, use Promise.all. For shared data across components, wrap the fetch in cache().
The old question of "where does this data come from and how do I thread it through the tree" mostly disappears. Each component knows what it needs and fetches it. The runtime takes care of deduplication and caching.
Newsletter
A weekly newsletter on React, Next.js, AI-assisted development, and engineering. No spam, unsubscribe any time.