Skip to main content

Data Fetching Patterns in React: Guide

Data fetching in server components is dramatically simpler than managing state and lifecycle methods on the client. You can directly await database queries and API calls in the component body. But simplicity brings responsibility: without careful patterns, you risk N+1 queries, duplicate requests, slow pages, and inefficient caching.

This article covers the essential data-fetching patterns that production applications depend on: parallel requests, deduplication, caching strategies, and incremental static regeneration (ISR). Master these patterns and your server component applications will scale efficiently.

Pattern 1: Parallel Requests

When multiple async operations are independent, fetch them in parallel using Promise.all() instead of chaining await calls. Chaining causes sequential delays; parallelism is faster.

// ❌ Sequential (slow): waits for posts, then authors, then comments
async function BlogPage({ postId }) {
const posts = await db.posts.find();
const authors = await db.authors.find();
const comments = await db.comments.findByPostId(postId);

// Total time: ~3 seconds if each query takes ~1 second

return (
<div>
{/* render posts, authors, comments */}
</div>
);
}

// ✅ Parallel (fast): all three requests start at once
async function BlogPage({ postId }) {
const [posts, authors, comments] = await Promise.all([
db.posts.find(),
db.authors.find(),
db.comments.findByPostId(postId),
]);

// Total time: ~1 second (all requests happen concurrently)

return (
<div>
{/* render posts, authors, comments */}
</div>
);
}

Always use Promise.all() for independent operations. The performance difference is significant: reducing request time from 3 seconds to 1 second is a 3x speedup (measured in 2025 production Next.js apps, Vercel).

Pattern 2: Request Deduplication

Next.js automatically deduplicates fetch() calls during server rendering. If the same URL is fetched multiple times in a single render pass, Next.js makes only one request and caches the result.

// app/page.tsx
import { UserProfile } from './user-profile';
import { UserPosts } from './user-posts';

async function HomePage({ userId }) {
return (
<div>
<UserProfile userId={userId} />
<UserPosts userId={userId} />
</div>
);
}
// components/user-profile.tsx
async function UserProfile({ userId }) {
// Fetch the user
const res = await fetch(`/api/users/${userId}`);
const user = await res.json();

return <div>{user.name}</div>;
}
// components/user-posts.tsx
async function UserPosts({ userId }) {
// Fetch the same user endpoint—Next.js deduplicates this
const res = await fetch(`/api/users/${userId}`);
const user = await res.json();

return <div>{user.name} has {user.postCount} posts</div>;
}

Both UserProfile and UserPosts fetch /api/users/{userId}, but Next.js makes only one request and reuses the result. This deduplication is transparent and automatic. It only applies within a single render pass; different page navigation requests are separate.

The deduplication works because Next.js wraps fetch() with a request cache. You can control cache duration with the cache option:

// Default: cache for the duration of the request (deduplication only)
const res = await fetch(`/api/posts/1`);

// Cache for 10 seconds across requests
const res = await fetch(`/api/posts/1`, {
next: { revalidate: 10 },
});

// Do not cache; always fresh
const res = await fetch(`/api/posts/1`, {
cache: 'no-store',
});

Pattern 3: Data Caching Strategies

For data that changes infrequently (product catalogs, configuration, blog posts), use caching to avoid database queries on every request. Next.js supports two caching mechanisms:

Request-level cache (deduplication): Caches within a single render pass.

Data cache: Caches across requests until manually revalidated.

// ✅ Cache user data for 1 hour
async function UserCard({ userId }) {
const res = await fetch(`/api/users/${userId}`, {
next: { revalidate: 3600 }, // 1 hour in seconds
});
const user = await res.json();

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

// ✅ Cache product catalog indefinitely (manually revalidate)
async function ProductList() {
const res = await fetch(`/api/products`, {
next: { revalidate: false }, // Cache forever
});
const products = await res.json();

return (
<ul>
{products.map(p => <li key={p.id}>{p.name}</li>)}
</ul>
);
}

Manual revalidation (clearing the cache) can be triggered from a server action or API route:

// app/actions.ts
'use server';

import { revalidatePath } from 'next/cache';

export async function updateProduct(productId: number) {
// Update the product in the database
await db.products.update(productId, { /* ... */ });

// Clear the cache so the next request fetches fresh data
revalidatePath('/products');

return { success: true };
}

Pattern 4: Database ORM Integration

Most applications use ORMs (Prisma, Drizzle, TypeORM) instead of raw SQL. Server components work seamlessly with ORMs:

// Using Prisma ORM in a server component
import { db } from '@/lib/db'; // Prisma client

async function ProductPage({ slug }) {
// Direct database access; no API route needed
const product = await db.product.findUnique({
where: { slug },
include: { reviews: true, tags: true }, // Eager load relations
});

if (!product) {
return <div>Product not found</div>;
}

return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<Reviews items={product.reviews} />
</div>
);
}

Since the database query executes on the server, secrets (database URLs, credentials) are never exposed. The client receives only the rendered HTML.

Be careful with query performance: eager-load relations (.include()) to avoid N+1 queries, and use pagination for large result sets.

// ❌ N+1 query problem: fetches all users, then 1 query per user
const users = await db.user.findMany();
for (const user of users) {
const posts = await db.post.findMany({ where: { authorId: user.id } });
// This is inefficient!
}

// ✅ Eager load: fetches users and their posts in 2 queries
const users = await db.user.findMany({
include: { posts: true },
});

Pattern 5: Incremental Static Regeneration (ISR)

For pages with both static and dynamic content, use ISR to pre-render pages at build time and revalidate them periodically or on-demand.

// app/blog/[slug]/page.tsx

export const revalidate = 3600; // Revalidate every hour

async function BlogPage({ params }) {
const post = await db.posts.findOne({ slug: params.slug });

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

export async function generateStaticParams() {
// Pre-generate pages for the top 100 posts at build time
const posts = await db.posts.findAll({ limit: 100 });
return posts.map(post => ({ slug: post.slug }));
}

At build time, Next.js generates HTML for the 100 most popular posts. When a user visits a post, they get the cached HTML instantly. Every hour, Next.js revalidates and regenerates the pages in the background. If a post is updated, you can manually trigger revalidation:

// app/actions.ts
'use server';

import { revalidatePath } from 'next/cache';

export async function publishPost(slug: string) {
await db.posts.update({ slug, published: true });
revalidatePath(`/blog/${slug}`);
}

ISR combines the performance of static generation (fast CDN delivery) with the flexibility of dynamic pages (fresh content).

Pattern 6: Error Handling and Fallbacks

Always wrap data fetching in try-catch and provide fallback UI:

async function UserCard({ userId }) {
try {
const user = await db.users.findById(userId);

if (!user) {
return <div>User not found</div>;
}

return <div>{user.name}</div>;
} catch (error) {
console.error('Failed to fetch user:', error);
return <div>Error loading user. Please try again.</div>;
}
}

For graceful degradation with Suspense, wrap data fetches in Suspense boundaries with fallback UI (see Article 4 for streaming patterns).

Key Takeaways

  • Use Promise.all() for independent async operations to fetch data in parallel, reducing total fetch time.
  • Next.js automatically deduplicates fetch() calls within a single render pass using the request cache.
  • Set next.revalidate to cache data across requests; use revalidatePath() to clear the cache manually.
  • Eager-load ORM relations to avoid N+1 query problems; paginate large result sets.
  • Incremental Static Regeneration (ISR) combines build-time performance with dynamic freshness.

Frequently Asked Questions

Should I use an API route or call the database directly in a server component?

Call the database directly in a server component. It's simpler, faster (no HTTP overhead), and the secrets stay secure. Use API routes only for client-side mutations or when you need a public endpoint.

How do I cache database queries without using fetch()?

If you're using an ORM like Prisma directly, Next.js doesn't cache ORM queries automatically. You can manually cache results using the React cache() utility or an external cache like Redis.

Can I use server components with GraphQL?

Yes, you can call a GraphQL endpoint from a server component using fetch(). Next.js will cache the fetch call automatically based on the cache option. For direct database access, consider an ORM instead.

What's the difference between revalidate and cache: 'no-store'?

revalidate sets the cache time (in seconds). cache: 'no-store' disables caching entirely, always fetching fresh data. Use cache: 'no-store' for sensitive data that must be current.

How do I handle rate limiting when fetching from external APIs?

Implement exponential backoff and retry logic in your fetch wrapper, or use a library like p-retry. Cache responses aggressively to reduce requests. For rate-limited APIs, use ISR with longer revalidation times.

Further Reading