NextJS DOC
Part 5 of 15Next.js Data Fetching: Complete Architecture Guide
Next.js Data Fetching: Complete Architecture Guide
Introduction: The Data Fetching Paradigm Shift
Next.js App Router fundamentally changes how we think about data fetching. Instead of the traditional client-side fetch-on-mount pattern, the default approach is server-side data fetching in async Server Components. This enables direct database access, eliminates client-server waterfalls, and keeps sensitive logic server-side.
This guide covers every data fetching pattern—from basic server fetches to advanced streaming, parallel requests, and cross-component data sharing.
Data Fetching Architecture Overview
┌─────────────────────────────────────────────────────────────────────┐
│ DATA FETCHING ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ SERVER │ │
│ │ │ │
│ │ Server Components (Default) │ │
│ │ ───────────────────────────── │ │
│ │ • Direct fetch() calls │ │
│ │ • Direct database queries (ORM) │ │
│ │ • Access to secrets/credentials │ │
│ │ • Automatic request deduplication │ │
│ │ • Streaming with Suspense │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌──────────────┐ │ │
│ │ │ fetch() │ │ Prisma/ │ │ Redis/ │ │ │
│ │ │ API/REST │ │ Drizzle │ │ Cache │ │ │
│ │ └──────┬──────┘ └──────┬──────┘ └──────┬───────┘ │ │
│ │ │ │ │ │ │
│ │ └───────────────────┴──────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ RSC Payload + HTML │ │
│ │ │ │
│ └──────────────────────────────┬───────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ CLIENT │ │
│ │ │ │
│ │ Client Components ('use client') │ │
│ │ ───────────────────────────────── │ │
│ │ • use() hook for streamed promises │ │
│ │ • SWR / React Query for client fetching │ │
│ │ • Real-time subscriptions │ │
│ │ • User-triggered fetches │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
Server-Side Data Fetching
The Foundation: Async Server Components
Server Components can be async, allowing direct await of data fetching:
// app/posts/page.tsx
// This entire component runs on the server
export default async function PostsPage() {
// Direct fetch - no useEffect, no loading state management
const response = await fetch('https://api.example.com/posts');
const posts = await response.json();
return (
<ul>
{posts.map((post) => (
<li key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</li>
))}
</ul>
);
}
Fetching with the fetch API
// app/products/[id]/page.tsx
interface Product {
id: string;
name: string;
price: number;
description: string;
}
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const response = await fetch(`https://api.example.com/products/${id}`, {
// Request-specific options
headers: {
'Authorization': `Bearer ${process.env.API_TOKEN}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
// This will trigger error.tsx
throw new Error(`Failed to fetch product: ${response.status}`);
}
const product: Product = await response.json();
return (
<div>
<h1>{product.name}</h1>
<p className="price">${product.price}</p>
<p>{product.description}</p>
</div>
);
}
Direct Database Access with ORMs
Server Components can directly query databases—no API layer needed:
// app/users/page.tsx
import { db } from '@/lib/db';
import { users, posts } from '@/lib/db/schema';
import { desc, eq } from 'drizzle-orm';
export default async function UsersPage() {
// Direct database query with Drizzle ORM
const allUsers = await db
.select({
id: users.id,
name: users.name,
email: users.email,
postCount: db.$count(posts, eq(posts.authorId, users.id)),
})
.from(users)
.orderBy(desc(users.createdAt))
.limit(50);
return (
<table>
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Posts</th>
</tr>
</thead>
<tbody>
{allUsers.map((user) => (
<tr key={user.id}>
<td>{user.name}</td>
<td>{user.email}</td>
<td>{user.postCount}</td>
</tr>
))}
</tbody>
</table>
);
}
// With Prisma
import { prisma } from '@/lib/prisma';
export default async function DashboardPage() {
const [users, posts, comments] = await Promise.all([
prisma.user.count(),
prisma.post.count(),
prisma.comment.count(),
]);
return (
<div className="stats">
<Stat label="Users" value={users} />
<Stat label="Posts" value={posts} />
<Stat label="Comments" value={comments} />
</div>
);
}
Request Memoization
React automatically memoizes identical fetch requests within a render pass:
// lib/data.ts
export async function getUser(id: string) {
const response = await fetch(`https://api.example.com/users/${id}`);
return response.json();
}
// app/layout.tsx
import { getUser } from '@/lib/data';
export default async function Layout({ children }) {
const user = await getUser('123'); // Request #1
return (
<div>
<Header user={user} />
{children}
</div>
);
}
// app/page.tsx
import { getUser } from '@/lib/data';
export default async function Page() {
const user = await getUser('123'); // Same request - DEDUPLICATED
// Only ONE actual fetch happens!
return <Profile user={user} />;
}
Important: Memoization only works for:
GETrequests withfetch()- Identical URLs and options
- Within the same render pass (not across requests)
Streaming: Progressive Data Loading
The Problem: Waterfall Blocking
Without streaming, slow data fetches block the entire page:
┌─────────────────────────────────────────────────────────────────────┐
│ WITHOUT STREAMING │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Request ──────────────────────────────────────────────► Response │
│ │ │ │
│ │◄─────── Fetch all data (2000ms) ──────────►│ │
│ │ │ │
│ User │ [Blank screen] │ Page │
│ waits │ │ renders │
│ │
└─────────────────────────────────────────────────────────────────────┘
Solution 1: loading.tsx for Route-Level Streaming
// app/dashboard/loading.tsx
export default function DashboardLoading() {
return (
<div className="dashboard-skeleton">
<div className="skeleton-header animate-pulse" />
<div className="skeleton-stats">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="skeleton-stat animate-pulse" />
))}
</div>
<div className="skeleton-chart animate-pulse" />
</div>
);
}
// app/dashboard/page.tsx
export default async function DashboardPage() {
// This fetch blocks, but loading.tsx shows immediately
const data = await fetchDashboardData();
return <Dashboard data={data} />;
}
How it works internally:
// Next.js transforms this:
<Layout>
<DashboardPage />
</Layout>
// Into this:
<Layout>
<Suspense fallback={<DashboardLoading />}>
<DashboardPage />
</Suspense>
</Layout>
Solution 2: <Suspense> for Component-Level Streaming
More granular control over what streams:
// app/dashboard/page.tsx
import { Suspense } from 'react';
export default function DashboardPage() {
return (
<div className="dashboard">
{/* Header renders immediately */}
<DashboardHeader />
{/* Stats stream in first (fast query) */}
<Suspense fallback={<StatsSkeleton />}>
<DashboardStats />
</Suspense>
<div className="dashboard-grid">
{/* Chart streams independently */}
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
{/* Table streams independently */}
<Suspense fallback={<TableSkeleton />}>
<RecentOrders />
</Suspense>
</div>
</div>
);
}
// Each component fetches its own data
async function DashboardStats() {
const stats = await fetchStats(); // 200ms
return <StatsDisplay stats={stats} />;
}
async function RevenueChart() {
const data = await fetchRevenueData(); // 800ms
return <Chart data={data} />;
}
async function RecentOrders() {
const orders = await fetchRecentOrders(); // 500ms
return <OrdersTable orders={orders} />;
}
Streaming Timeline:
┌─────────────────────────────────────────────────────────────────────┐
│ WITH SUSPENSE STREAMING │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 0ms Header + Skeletons sent immediately │
│ [Header] [StatsSkeleton] [ChartSkeleton] [TableSkeleton] │
│ │
│ 200ms Stats stream in │
│ [Header] [Stats ✓] [ChartSkeleton] [TableSkeleton] │
│ │
│ 500ms Orders stream in │
│ [Header] [Stats ✓] [ChartSkeleton] [Orders ✓] │
│ │
│ 800ms Chart streams in (slowest) │
│ [Header] [Stats ✓] [Chart ✓] [Orders ✓] │
│ │
│ Result: User sees content progressively, not all-or-nothing │
│ │
└─────────────────────────────────────────────────────────────────────┘
Creating Meaningful Loading States
// components/skeletons/stats-skeleton.tsx
export function StatsSkeleton() {
return (
<div className="stats-grid">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="stat-card">
{/* Skeleton matches actual stat card layout */}
<div className="skeleton h-4 w-20 mb-2" /> {/* Label */}
<div className="skeleton h-8 w-32" /> {/* Value */}
<div className="skeleton h-3 w-16 mt-2" /> {/* Change % */}
</div>
))}
</div>
);
}
// components/skeletons/chart-skeleton.tsx
export function ChartSkeleton() {
return (
<div className="chart-container">
<div className="skeleton h-6 w-40 mb-4" /> {/* Title */}
<div className="chart-area">
{/* Fake chart bars */}
{[40, 65, 45, 80, 55, 70, 90].map((height, i) => (
<div
key={i}
className="skeleton chart-bar"
style={{ height: `${height}%` }}
/>
))}
</div>
</div>
);
}
Client-Side Data Fetching
Pattern 1: Streaming Promises with use() Hook
Pass promises from Server to Client Components:
// app/posts/page.tsx (Server Component)
import { Suspense } from 'react';
import { PostList } from './post-list';
async function getPosts() {
const response = await fetch('https://api.example.com/posts');
return response.json();
}
export default function PostsPage() {
// Don't await - pass the promise
const postsPromise = getPosts();
return (
<div>
<h1>Blog Posts</h1>
<Suspense fallback={<PostsSkeleton />}>
<PostList postsPromise={postsPromise} />
</Suspense>
</div>
);
}
// app/posts/post-list.tsx (Client Component)
'use client';
import { use } from 'react';
interface Post {
id: string;
title: string;
excerpt: string;
}
interface PostListProps {
postsPromise: Promise<Post[]>;
}
export function PostList({ postsPromise }: PostListProps) {
// use() unwraps the promise, suspends until resolved
const posts = use(postsPromise);
return (
<ul>
{posts.map((post) => (
<li key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</li>
))}
</ul>
);
}
Pattern 2: SWR for Client-Side Fetching
When you need client-side revalidation, mutations, or real-time updates:
// app/notifications/page.tsx
'use client';
import useSWR from 'swr';
const fetcher = (url: string) => fetch(url).then((r) => r.json());
interface Notification {
id: string;
message: string;
read: boolean;
createdAt: string;
}
export default function NotificationsPage() {
const {
data: notifications,
error,
isLoading,
mutate,
} = useSWR<Notification[]>('/api/notifications', fetcher, {
refreshInterval: 30000, // Poll every 30 seconds
revalidateOnFocus: true,
});
const markAsRead = async (id: string) => {
// Optimistic update
mutate(
notifications?.map((n) =>
n.id === id ? { ...n, read: true } : n
),
false // Don't revalidate yet
);
await fetch(`/api/notifications/${id}/read`, { method: 'POST' });
mutate(); // Revalidate after mutation
};
if (isLoading) return <NotificationsSkeleton />;
if (error) return <ErrorDisplay error={error} />;
if (!notifications?.length) return <EmptyState />;
return (
<ul>
{notifications.map((notification) => (
<li
key={notification.id}
className={notification.read ? 'read' : 'unread'}
onClick={() => markAsRead(notification.id)}
>
{notification.message}
</li>
))}
</ul>
);
}
Pattern 3: React Query for Complex Data Requirements
// providers/query-provider.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
gcTime: 5 * 60 * 1000, // 5 minutes
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
// hooks/use-products.ts
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
interface Product {
id: string;
name: string;
price: number;
}
export function useProducts(category?: string) {
return useQuery({
queryKey: ['products', category],
queryFn: async () => {
const params = category ? `?category=${category}` : '';
const response = await fetch(`/api/products${params}`);
if (!response.ok) throw new Error('Failed to fetch products');
return response.json() as Promise<Product[]>;
},
});
}
export function useCreateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (newProduct: Omit<Product, 'id'>) => {
const response = await fetch('/api/products', {
method: 'POST',
body: JSON.stringify(newProduct),
});
return response.json();
},
onSuccess: () => {
// Invalidate and refetch products
queryClient.invalidateQueries({ queryKey: ['products'] });
},
});
}
// app/products/page.tsx
'use client';
import { useProducts, useCreateProduct } from '@/hooks/use-products';
export default function ProductsPage() {
const { data: products, isLoading, error } = useProducts();
const createProduct = useCreateProduct();
if (isLoading) return <ProductsSkeleton />;
if (error) return <ErrorDisplay error={error} />;
return (
<div>
<ProductList products={products} />
<CreateProductForm
onSubmit={(data) => createProduct.mutate(data)}
isLoading={createProduct.isPending}
/>
</div>
);
}
Sequential vs Parallel Data Fetching
Sequential (Waterfall) - When Necessary
Use when one request depends on another's result:
// app/artist/[username]/page.tsx
export default async function ArtistPage({
params,
}: {
params: Promise<{ username: string }>;
}) {
const { username } = await params;
// Sequential: playlists need artistId from artist
const artist = await getArtist(username);
// This waits for artist to complete
const playlists = await getArtistPlaylists(artist.id);
return (
<div>
<ArtistHeader artist={artist} />
<PlaylistGrid playlists={playlists} />
</div>
);
}
Optimize with Suspense:
export default async function ArtistPage({
params,
}: {
params: Promise<{ username: string }>;
}) {
const { username } = await params;
const artist = await getArtist(username);
return (
<div>
{/* Artist info shows immediately */}
<ArtistHeader artist={artist} />
{/* Playlists stream in after */}
<Suspense fallback={<PlaylistSkeleton />}>
<ArtistPlaylists artistId={artist.id} />
</Suspense>
</div>
);
}
async function ArtistPlaylists({ artistId }: { artistId: string }) {
const playlists = await getArtistPlaylists(artistId);
return <PlaylistGrid playlists={playlists} />;
}
Parallel - When Possible
When requests are independent, fetch them simultaneously:
// ❌ BAD: Sequential (2000ms total)
export default async function DashboardPage() {
const users = await getUsers(); // 500ms
const posts = await getPosts(); // 800ms
const comments = await getComments(); // 700ms
// Total: 2000ms
return <Dashboard users={users} posts={posts} comments={comments} />;
}
// ✅ GOOD: Parallel (800ms total - slowest request)
export default async function DashboardPage() {
// Start all requests immediately
const usersPromise = getUsers(); // 500ms
const postsPromise = getPosts(); // 800ms
const commentsPromise = getComments(); // 700ms
// Wait for all to complete
const [users, posts, comments] = await Promise.all([
usersPromise,
postsPromise,
commentsPromise,
]);
// Total: 800ms (max of all three)
return <Dashboard users={users} posts={posts} comments={comments} />;
}
Handling Partial Failures
// Promise.all fails if ANY request fails
// Use Promise.allSettled for partial success handling
export default async function DashboardPage() {
const results = await Promise.allSettled([
getUsers(),
getPosts(),
getComments(),
]);
const [usersResult, postsResult, commentsResult] = results;
return (
<Dashboard
users={usersResult.status === 'fulfilled' ? usersResult.value : null}
posts={postsResult.status === 'fulfilled' ? postsResult.value : null}
comments={commentsResult.status === 'fulfilled' ? commentsResult.value : null}
errors={{
users: usersResult.status === 'rejected' ? usersResult.reason : null,
posts: postsResult.status === 'rejected' ? postsResult.reason : null,
comments: commentsResult.status === 'rejected' ? commentsResult.reason : null,
}}
/>
);
}
Data Sharing with React.cache
The Problem: Duplicate Fetches
Multiple components need the same data:
// Without cache - multiple identical fetches
async function Header() {
const user = await getUser(); // Fetch #1
return <header>{user.name}</header>;
}
async function Sidebar() {
const user = await getUser(); // Fetch #2 (duplicate!)
return <aside>{user.role}</aside>;
}
async function Profile() {
const user = await getUser(); // Fetch #3 (duplicate!)
return <div>{user.email}</div>;
}
Solution: React.cache for Request-Level Memoization
// lib/data.ts
import { cache } from 'react';
// Wrap data fetching functions with cache
export const getUser = cache(async (id: string) => {
console.log('Fetching user:', id); // Only logs ONCE per request
const response = await fetch(`https://api.example.com/users/${id}`);
return response.json();
});
export const getProducts = cache(async (category?: string) => {
const params = category ? `?category=${category}` : '';
const response = await fetch(`https://api.example.com/products${params}`);
return response.json();
});
// Now multiple components can call getUser() - only one fetch happens
async function Header() {
const user = await getUser('123'); // Actual fetch
return <header>{user.name}</header>;
}
async function Sidebar() {
const user = await getUser('123'); // Returns cached result
return <aside>{user.role}</aside>;
}
async function Profile() {
const user = await getUser('123'); // Returns cached result
return <div>{user.email}</div>;
}
Sharing Data Between Server and Client Components
// lib/user.ts
import { cache } from 'react';
export const getUser = cache(async () => {
const response = await fetch('https://api.example.com/user');
return response.json();
});
// context/user-context.tsx
'use client';
import { createContext, useContext } from 'react';
interface User {
id: string;
name: string;
email: string;
}
export const UserContext = createContext<Promise<User> | null>(null);
export function UserProvider({
children,
userPromise,
}: {
children: React.ReactNode;
userPromise: Promise<User>;
}) {
return (
<UserContext.Provider value={userPromise}>
{children}
</UserContext.Provider>
);
}
export function useUser() {
const context = useContext(UserContext);
if (!context) {
throw new Error('useUser must be used within UserProvider');
}
return context;
}
// app/layout.tsx (Server Component)
import { UserProvider } from '@/context/user-context';
import { getUser } from '@/lib/user';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
// Don't await - pass the promise
const userPromise = getUser();
return (
<html>
<body>
<UserProvider userPromise={userPromise}>
{children}
</UserProvider>
</body>
</html>
);
}
// components/user-profile.tsx (Client Component)
'use client';
import { use, Suspense } from 'react';
import { useUser } from '@/context/user-context';
function UserProfileContent() {
const userPromise = useUser();
const user = use(userPromise); // Suspends until resolved
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
export function UserProfile() {
return (
<Suspense fallback={<ProfileSkeleton />}>
<UserProfileContent />
</Suspense>
);
}
// app/dashboard/page.tsx (Server Component)
import { getUser } from '@/lib/user';
export default async function DashboardPage() {
// Same cached result - no duplicate fetch
const user = await getUser();
return (
<div>
<h1>Welcome, {user.name}</h1>
{/* ... */}
</div>
);
}
Error Handling Patterns
Route-Level Error Handling
// app/products/error.tsx
'use client';
interface ErrorProps {
error: Error & { digest?: string };
reset: () => void;
}
export default function ProductsError({ error, reset }: ErrorProps) {
return (
<div className="error-container">
<h2>Failed to load products</h2>
<p>{error.message}</p>
{error.digest && (
<p className="error-id">Error ID: {error.digest}</p>
)}
<button onClick={reset}>Try again</button>
</div>
);
}
Component-Level Error Boundaries
// components/error-boundary.tsx
'use client';
import { Component, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback: ReactNode | ((error: Error, reset: () => void) => ReactNode);
}
interface State {
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
reset = () => {
this.setState({ error: null });
};
render() {
if (this.state.error) {
if (typeof this.props.fallback === 'function') {
return this.props.fallback(this.state.error, this.reset);
}
return this.props.fallback;
}
return this.props.children;
}
}
// Usage
<ErrorBoundary
fallback={(error, reset) => (
<div>
<p>Error: {error.message}</p>
<button onClick={reset}>Retry</button>
</div>
)}
>
<Suspense fallback={<Skeleton />}>
<DataComponent />
</Suspense>
</ErrorBoundary>
Graceful Degradation
// app/dashboard/page.tsx
export default async function DashboardPage() {
return (
<div className="dashboard">
<DashboardHeader />
{/* Critical data - let error bubble up */}
<Suspense fallback={<StatsSkeleton />}>
<CriticalStats />
</Suspense>
{/* Non-critical - handle errors gracefully */}
<ErrorBoundary fallback={<RecommendationsUnavailable />}>
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations />
</Suspense>
</ErrorBoundary>
{/* Another non-critical section */}
<ErrorBoundary fallback={<ActivityUnavailable />}>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</ErrorBoundary>
</div>
);
}
Preloading Data
Preload Functions for Critical Data
// lib/data.ts
import { cache } from 'react';
export const getProduct = cache(async (id: string) => {
const response = await fetch(`https://api.example.com/products/${id}`);
return response.json();
});
// Preload function - starts fetch without blocking
export const preloadProduct = (id: string) => {
void getProduct(id);
};
// app/products/[id]/page.tsx
import { getProduct, preloadProduct } from '@/lib/data';
import { ProductDetails } from './product-details';
import { RelatedProducts } from './related-products';
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
// Start fetching product immediately
const product = await getProduct(id);
// Preload related products while rendering
product.relatedIds.forEach((relatedId) => {
preloadProduct(relatedId);
});
return (
<div>
<ProductDetails product={product} />
<Suspense fallback={<RelatedSkeleton />}>
<RelatedProducts ids={product.relatedIds} />
</Suspense>
</div>
);
}
Link Prefetching with Data
// components/product-card.tsx
'use client';
import Link from 'next/link';
import { preloadProduct } from '@/lib/data';
interface ProductCardProps {
product: {
id: string;
name: string;
price: number;
image: string;
};
}
export function ProductCard({ product }: ProductCardProps) {
return (
<Link
href={`/products/${product.id}`}
// Preload data when user hovers
onMouseEnter={() => preloadProduct(product.id)}
onFocus={() => preloadProduct(product.id)}
>
<div className="product-card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>${product.price}</p>
</div>
</Link>
);
}
Performance Patterns
Pattern 1: Colocate Data Fetching
Fetch data in the component that needs it:
// ✅ GOOD: Data fetching colocated with component
async function UserAvatar({ userId }: { userId: string }) {
const user = await getUser(userId);
return <Avatar src={user.avatar} alt={user.name} />;
}
// ❌ BAD: Prop drilling from parent
async function Page() {
const user = await getUser('123');
return <Layout user={user}><Content user={user} /></Layout>;
}
Pattern 2: Parallel with Independent Suspense
export default function DashboardPage() {
return (
<div className="grid grid-cols-2 gap-4">
{/* Each section loads independently */}
<Suspense fallback={<CardSkeleton />}>
<RevenueCard />
</Suspense>
<Suspense fallback={<CardSkeleton />}>
<UsersCard />
</Suspense>
<Suspense fallback={<CardSkeleton />}>
<OrdersCard />
</Suspense>
<Suspense fallback={<CardSkeleton />}>
<ProductsCard />
</Suspense>
</div>
);
}
// Each component fetches its own data
async function RevenueCard() {
const revenue = await getRevenue();
return <Card title="Revenue" value={`$${revenue}`} />;
}
Pattern 3: Staggered Loading for UX
export default function FeedPage() {
return (
<div>
{/* Critical content first */}
<Suspense fallback={<FeedSkeleton count={3} />}>
<InitialPosts />
</Suspense>
{/* Less critical, can wait */}
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
{/* Lowest priority */}
<Suspense fallback={null}>
<Analytics />
</Suspense>
</div>
);
}
Decision Framework
┌─────────────────────────────────────────────────────────────────────┐
│ DATA FETCHING DECISION TREE │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Where should I fetch data? │
│ │ │
│ ├── Does it need browser APIs or user interaction to trigger? │
│ │ └── YES → Client Component (SWR/React Query/use) │
│ │ │
│ ├── Does it need to update in real-time without refresh? │
│ │ └── YES → Client Component with polling/websockets │
│ │ │
│ ├── Is it sensitive data (secrets, direct DB)? │
│ │ └── YES → Server Component (mandatory) │
│ │ │
│ └── Default → Server Component (best performance) │
│ │
│ How should I handle loading? │
│ │ │
│ ├── Entire page should show skeleton? │
│ │ └── Use loading.tsx │
│ │ │
│ ├── Only parts of page should stream? │
│ │ └── Use <Suspense> with granular boundaries │
│ │ │
│ └── Some content more important than others? │
│ └── Use nested Suspense with different fallbacks │
│ │
│ How should I handle multiple requests? │
│ │ │
│ ├── Requests depend on each other? │
│ │ └── Sequential with Suspense for non-blocking parts │
│ │ │
│ └── Requests are independent? │
│ └── Parallel with Promise.all or separate Suspense │
│ │
└─────────────────────────────────────────────────────────────────────┘
Key Takeaways
-
Server Components are the default: Fetch data directly in async Server Components—no API routes needed for internal data.
-
Streaming improves perceived performance: Use
loading.tsxfor route-level and<Suspense>for component-level progressive loading. -
Parallel beats sequential: Use
Promise.all()for independent requests. Reserve sequential fetching for dependent data. -
React.cacheprevents duplicate fetches: Wrap data functions withcache()for request-level memoization across components. -
Colocate data fetching: Fetch data in the component that needs it. Request deduplication makes prop drilling unnecessary.
-
use()hook bridges server and client: Pass promises from Server to Client Components for streaming data. -
SWR/React Query for client-side needs: Use when you need real-time updates, mutations, or user-triggered fetches.
-
Error boundaries for graceful degradation: Non-critical sections should fail gracefully, not break the entire page.
-
Preload for perceived speed: Start fetching data before navigation (on hover/focus) for instant transitions.
-
Meaningful loading states: Skeletons that match actual content layout feel faster than generic spinners.
What did you think?
Related Posts
April 4, 202697 min
Next.js Proxy Deep Dive: Edge-First Request Interception
April 4, 2026164 min
Next.js Route Handlers Deep Dive: Building Production APIs with Web Standards
April 4, 202691 min