Skip to main content

How to Fetch Data in Next.js Server Components

Fetching data in a Next.js Server Component is as simple as writing an async function and awaiting fetch or database calls inside it. Server Components run only on the server and have full access to databases, APIs, and environment variables. By fetching server-side, you embed data directly in the HTML payload, eliminating client-side round trips and improving page speed by 200–400 ms on typical networks (Vercel, 2026).

Writing Your First Async Server Component

Every Next.js Server Component is async by default in the App Router. Simply define your component as async and fetch data using await:

// app/articles/page.js
export default async function ArticlesPage() {
const res = await fetch('https://api.example.com/articles', {
next: { revalidate: 3600 } // Cache for 1 hour
});
const articles = await res.json();

return (
<div>
<h1>Articles</h1>
<ul>
{articles.map(article => (
<li key={article.id}>{article.title}</li>
))}
</ul>
</div>
);
}

The component awaits the fetch, then renders with the data. The browser receives HTML with <li> elements populated. No JavaScript overhead, no loading state, data visible immediately. This is the foundation of fast Next.js pages.

Using the Enhanced fetch Function

Next.js extends the native fetch with a next object that controls caching and revalidation:

const res = await fetch(url, {
next: {
revalidate: 3600, // Re-fetch every 3600 seconds
tags: ['articles'] // Tag for on-demand revalidation
}
});

The revalidate property sets cache duration in seconds. tags are labels for granular cache invalidation—useful when multiple pages cache the same data but you want to invalidate all at once (more in article 8). Every fetch call in a Server Component is automatically cached by Next.js unless you explicitly disable it.

Fetching from Databases Directly

Server Components give you direct database access without exposing credentials to the browser. Use any client library:

// app/products/page.js (with Prisma)
import { prisma } from '@/lib/prisma';

export default async function ProductsPage() {
const products = await prisma.product.findMany({
take: 20,
orderBy: { createdAt: 'desc' }
});

return (
<div>
<h1>Featured Products</h1>
<div className="grid">
{products.map(product => (
<div key={product.id} className="card">
<h3>{product.name}</h3>
<p>${product.price}</p>
</div>
))}
</div>
</div>
);
}

Prisma, MongoDB, PostgreSQL, or any database driver works directly in Server Components. The database call executes on the server; the browser never sees credentials. This pattern is far more secure than Client Components that use API routes (which could be reverse-engineered).

Error Handling and Fallbacks

Network requests can fail. Wrap fetches in try/catch to handle errors gracefully:

export default async function DataPage() {
try {
const res = await fetch('https://api.example.com/data', {
next: { revalidate: 60 }
});

if (!res.ok) {
throw new Error(`API returned ${res.status}`);
}

const data = await res.json();
return <div>{data.message}</div>;
} catch (error) {
return (
<div className="error">
<h2>Failed to load data</h2>
<p>{error.message}</p>
</div>
);
}
}

Returning an error UI inside the component is straightforward. For more complex error boundaries, wrap your component in a React Error Boundary. In production, log errors to a service like Sentry for monitoring (Sentry docs, 2025).

Parallel Fetches for Speed

If your page needs multiple pieces of data, fetch them in parallel using Promise.all() to speed up rendering:

// Parallel: both fetches execute simultaneously
export default async function DashboardPage() {
const [user, posts, comments] = await Promise.all([
fetch('https://api.example.com/user').then(r => r.json()),
fetch('https://api.example.com/posts').then(r => r.json()),
fetch('https://api.example.com/comments').then(r => r.json())
]);

return (
<div>
<h1>{user.name}'s Dashboard</h1>
<section>
<h2>Posts ({posts.length})</h2>
{posts.map(post => <div key={post.id}>{post.title}</div>)}
</section>
<section>
<h2>Comments ({comments.length})</h2>
{comments.map(c => <div key={c.id}>{c.text}</div>)}
</section>
</div>
);
}

Parallel fetches are 2–3x faster than sequential fetches because they execute concurrently. A single sequential fetch: 300 ms + 300 ms + 300 ms = 900 ms. Parallel: Math.max(300, 300, 300) = 300 ms. This is critical for page speed (Core Web Vitals).

Streaming with Suspense Boundaries

If one fetch is slow (e.g., a database query takes 2 seconds), it delays the entire page. Use Suspense boundaries to stream content progressively:

// app/blog/page.js (with Suspense)
import { Suspense } from 'react';

async function ArticleList() {
const articles = await fetch('https://api.example.com/articles', {
next: { revalidate: 3600 }
}).then(r => r.json());
return (
<ul>
{articles.map(a => <li key={a.id}>{a.title}</li>)}
</ul>
);
}

async function PopularArticles() {
// This fetch might be slower
await new Promise(r => setTimeout(r, 2000)); // Simulate slow fetch
return <div>Popular articles go here...</div>;
}

export default function BlogPage() {
return (
<div>
<h1>Blog</h1>
<Suspense fallback={<div>Loading articles...</div>}>
<ArticleList />
</Suspense>
<Suspense fallback={<div>Loading popular section...</div>}>
<PopularArticles />
</Suspense>
</div>
);
}

With Suspense, the browser shows the page shell and "Loading..." fallbacks immediately, then updates each section as its data arrives. This creates a better user experience than waiting for all data before showing anything. The browser sees HTML in under 100 ms instead of waiting 2 seconds (Chromium performance research, 2025).

Environment Variables and Secrets

Server Components can safely access environment variables. Next.js automatically exposes variables prefixed with NEXT_PUBLIC_ to the browser, and keeps others server-only:

// app/api-consumer/page.js
export default async function DataPage() {
// This API key is safe; it never leaves the server
const apiKey = process.env.SECRET_API_KEY;

const res = await fetch('https://api.example.com/data', {
headers: {
Authorization: `Bearer ${apiKey}`
}
});

const data = await res.json();
return <div>{data.content}</div>;
}

Environment variables without NEXT_PUBLIC_ prefix are never sent to the browser. This prevents accidental credential leaks. For API keys, database URIs, and tokens, always use non-public environment variables (recommended by OWASP, 2024).

Building a Real-World Example

Here's a complete example: an e-commerce product page that fetches product details, reviews, and stock:

// app/products/[id]/page.js
import { Suspense } from 'react';

async function ProductDetails({ id }) {
const res = await fetch(`https://api.example.com/products/${id}`, {
next: { revalidate: 3600 }
});
const product = await res.json();

return (
<div className="product">
<h1>{product.name}</h1>
<p className="price">${product.price}</p>
<p className="description">{product.description}</p>
</div>
);
}

async function Reviews({ id }) {
const res = await fetch(`https://api.example.com/products/${id}/reviews`, {
next: { revalidate: 300 } // Revalidate more often for reviews
});
const reviews = await res.json();

return (
<section className="reviews">
<h2>Reviews</h2>
{reviews.map(r => (
<div key={r.id} className="review">
<p className="rating">Rating: {r.rating}/5</p>
<p>{r.text}</p>
</div>
))}
</section>
);
}

export default async function ProductPage({ params }) {
return (
<div>
<Suspense fallback={<div>Loading product...</div>}>
<ProductDetails id={params.id} />
</Suspense>
<Suspense fallback={<div>Loading reviews...</div>}>
<Reviews id={params.id} />
</Suspense>
</div>
);
}

Product details are cached for 1 hour (unlikely to change), reviews for 5 minutes (change more often). The page renders progressively: product details arrive first, reviews stream in separately.

Key Takeaways

  • Async Server Components fetch data server-side and embed it in HTML, eliminating client-side round trips.
  • Use fetch with next: { revalidate } for HTTP APIs; use database clients directly for backend queries.
  • Parallel fetches with Promise.all() are 2–3x faster than sequential fetches.
  • Suspense boundaries stream content progressively, showing the page shell before slow data arrives.
  • Environment variables without NEXT_PUBLIC_ prefix stay server-only, keeping credentials safe.

Frequently Asked Questions

Can I use async/await syntax directly in the component body?

Yes. Server Components are async by default in Next.js 13+. Any async operation (fetch, database call, etc.) can be awaited directly in the component function.

What happens if a fetch times out?

The entire page rendering fails and returns an error. Wrap fetches in try/catch and return a fallback UI on error. For better control, set fetch timeout using AbortController: const controller = new AbortController(); setTimeout(() => controller.abort(), 5000); then pass signal: controller.signal to fetch options.

Can I cache fetch results beyond the revalidate time?

Yes. Use a third-party cache like Redis or memcache before the fetch. Or implement a custom cache in your Server Component using Node.js' cache() function from React (documentation in Next.js docs, 2026).

How do I handle authentication in Server Components?

Use middleware or server-side cookies. Next.js provides cookies() function in Server Components to read session cookies set by your auth provider (NextAuth, Auth0, etc.). Never expose auth tokens to the browser.

Can a Server Component re-fetch data on user interaction?

Not directly. Server Components run once on the server. To re-fetch on user interaction, nest a Client Component that calls an API route. Or use a form submission to trigger server-side revalidation.

Further Reading