Back to Blog

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:

  • GET requests with fetch()
  • 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>
  );
}
// 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

  1. Server Components are the default: Fetch data directly in async Server Components—no API routes needed for internal data.

  2. Streaming improves perceived performance: Use loading.tsx for route-level and <Suspense> for component-level progressive loading.

  3. Parallel beats sequential: Use Promise.all() for independent requests. Reserve sequential fetching for dependent data.

  4. React.cache prevents duplicate fetches: Wrap data functions with cache() for request-level memoization across components.

  5. Colocate data fetching: Fetch data in the component that needs it. Request deduplication makes prop drilling unnecessary.

  6. use() hook bridges server and client: Pass promises from Server to Client Components for streaming data.

  7. SWR/React Query for client-side needs: Use when you need real-time updates, mutations, or user-triggered fetches.

  8. Error boundaries for graceful degradation: Non-critical sections should fail gracefully, not break the entire page.

  9. Preload for perceived speed: Start fetching data before navigation (on hover/focus) for instant transitions.

  10. Meaningful loading states: Skeletons that match actual content layout feel faster than generic spinners.

What did you think?

Related Posts

© 2026 Vidhya Sagar Thakur. All rights reserved.