DEV Community

Cover image for How Data Fetching Works in Next.js: Server vs Client Components
sudip khatiwada
sudip khatiwada

Posted on

How Data Fetching Works in Next.js: Server vs Client Components

Next.js has revolutionized how we handle data fetching in React applications, especially with the introduction of Server Components in Next.js 13+. Understanding when and how to fetch data in server versus client components is crucial for building performant, SEO-friendly applications.

Table of Contents

What are Server and Client Components?

Server Components

Server Components run on the server during the build process or at request time. They have direct access to server-side resources like databases, file systems, and APIs without exposing sensitive information to the client.

Key characteristics:

  • Execute on the server
  • Can directly access databases and APIs
  • Don't include JavaScript in the client bundle
  • Cannot use browser-only features (useState, useEffect, event handlers)
  • Perfect for SEO and initial page load performance

Client Components

Client Components run in the browser and provide interactivity. They're the traditional React components we're familiar with, marked with the 'use client' directive in Next.js 13+.

Key characteristics:

  • Execute in the browser
  • Can use React hooks and browser APIs
  • Handle user interactions
  • Include JavaScript in the client bundle
  • Required for dynamic, interactive features

Server Component Data Fetching

Server Components can fetch data directly using async/await without any special hooks or libraries. This is one of their biggest advantages.

Basic Server Component Data Fetching

// app/posts/page.js (Server Component by default)
async function getPosts() {
  const res = await fetch('https://jsonplaceholder.typicode.com/posts', {
    cache: 'force-cache' // This will cache the request
  });

  if (!res.ok) {
    throw new Error('Failed to fetch posts');
  }

  return res.json();
}

export default async function PostsPage() {
  const posts = await getPosts();

  return (
    <div>
      <h1>Latest Posts</h1>
      {posts.map(post => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.body}</p>
        </article>
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Advanced Server Component with Database Access

// app/users/[id]/page.js
import { db } from '@/lib/database';

async function getUser(id) {
  // Direct database access in Server Component
  try {
    const user = await db.user.findUnique({
      where: { id: parseInt(id) },
      include: {
        posts: true,
        profile: true
      }
    });
    return user;
  } catch (error) {
    console.error('Database error:', error);
    return null;
  }
}

export default async function UserProfile({ params }) {
  const user = await getUser(params.id);

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

  return (
    <div>
      <h1>{user.name}</h1>
      <p>Email: {user.email}</p>
      <h2>Recent Posts</h2>
      {user.posts.map(post => (
        <div key={post.id}>
          <h3>{post.title}</h3>
          <p>{post.excerpt}</p>
        </div>
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Caching Strategies in Server Components

Next.js provides several caching options for server-side data fetching:

// Different caching strategies
async function fetchWithCaching() {
  // 1. Cache forever (until manually invalidated)
  const staticData = await fetch('https://api.example.com/static', {
    cache: 'force-cache'
  });

  // 2. Revalidate every 60 seconds
  const revalidatedData = await fetch('https://api.example.com/dynamic', {
    next: { revalidate: 60 }
  });

  // 3. Never cache (always fresh)
  const freshData = await fetch('https://api.example.com/realtime', {
    cache: 'no-store'
  });

  return {
    static: await staticData.json(),
    revalidated: await revalidatedData.json(),
    fresh: await freshData.json()
  };
}
Enter fullscreen mode Exit fullscreen mode

Client Component Data Fetching

Client Components require the 'use client' directive and use traditional React patterns for data fetching.

Using useEffect for Data Fetching

'use client';

import { useState, useEffect } from 'react';

export default function ClientPostsList() {
  const [posts, setPosts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    async function fetchPosts() {
      try {
        const response = await fetch('/api/posts');
        if (!response.ok) {
          throw new Error('Failed to fetch');
        }
        const data = await response.json();
        setPosts(data);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    }

    fetchPosts();
  }, []);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;

  return (
    <div>
      {posts.map(post => (
        <div key={post.id}>
          <h3>{post.title}</h3>
          <p>{post.body}</p>
        </div>
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Using SWR for Enhanced Client-Side Data Fetching

SWR provides caching, revalidation, and error handling out of the box:

'use client';

import useSWR from 'swr';

const fetcher = (url) => fetch(url).then(res => res.json());

export default function PostsWithSWR() {
  const { data: posts, error, isLoading } = useSWR('/api/posts', fetcher, {
    refreshInterval: 30000, // Refresh every 30 seconds
    revalidateOnFocus: true
  });

  if (error) return <div>Failed to load posts</div>;
  if (isLoading) return <div>Loading...</div>;

  return (
    <div>
      <h2>Posts (Auto-refreshing)</h2>
      {posts?.map(post => (
        <article key={post.id}>
          <h3>{post.title}</h3>
          <p>{post.body}</p>
        </article>
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Interactive Data Fetching with User Actions

'use client';

import { useState } from 'react';

export default function SearchPosts() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);

  const handleSearch = async (e) => {
    e.preventDefault();
    if (!query.trim()) return;

    setLoading(true);
    try {
      const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
      const data = await response.json();
      setResults(data);
    } catch (error) {
      console.error('Search failed:', error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div>
      <form onSubmit={handleSearch}>
        <input
          type="text"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          placeholder="Search posts..."
        />
        <button type="submit" disabled={loading}>
          {loading ? 'Searching...' : 'Search'}
        </button>
      </form>

      <div>
        {results.map(post => (
          <div key={post.id}>
            <h3>{post.title}</h3>
            <p>{post.excerpt}</p>
          </div>
        ))}
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

When to Use Which Approach

Use Server Components When:

  • SEO is important - Content needs to be indexed by search engines
  • Initial page load performance matters - Data is available immediately
  • Working with sensitive data - Database credentials, API keys stay on server
  • Content is relatively static - Blog posts, product listings, user profiles
  • Large data sets - Reduce client bundle size

Use Client Components When:

  • Interactivity is required - Forms, buttons, dynamic UI
  • Real-time updates needed - Chat applications, live data feeds
  • User-specific actions - Search, filtering, sorting
  • Browser APIs required - Geolocation, camera, local storage
  • Third-party client libraries - Analytics, chat widgets

Best Practices and Performance Tips

1. Hybrid Approach - Combine Both Patterns

// app/dashboard/page.js (Server Component)
import ClientInteractiveSection from './ClientInteractiveSection';

async function getInitialData() {
  const res = await fetch('https://api.example.com/dashboard-data');
  return res.json();
}

export default async function Dashboard() {
  const initialData = await getInitialData();

  return (
    <div>
      {/* Static content rendered on server */}
      <h1>Dashboard</h1>
      <div>
        <h2>Server-rendered Stats</h2>
        <p>Total Users: {initialData.totalUsers}</p>
        <p>Revenue: ${initialData.revenue}</p>
      </div>

      {/* Interactive component on client */}
      <ClientInteractiveSection initialData={initialData} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

2. Error Handling and Loading States

// app/products/page.js
import { Suspense } from 'react';
import ProductsList from './ProductsList';
import ProductsLoading from './ProductsLoading';

export default function ProductsPage() {
  return (
    <div>
      <h1>Our Products</h1>
      <Suspense fallback={<ProductsLoading />}>
        <ProductsList />
      </Suspense>
    </div>
  );
}

// ProductsList.js (Server Component)
async function getProducts() {
  try {
    const res = await fetch('https://api.example.com/products');
    if (!res.ok) throw new Error('Failed to fetch products');
    return res.json();
  } catch (error) {
    throw new Error('Products unavailable');
  }
}

export default async function ProductsList() {
  const products = await getProducts();

  return (
    <div>
      {products.map(product => (
        <div key={product.id}>
          <h3>{product.name}</h3>
          <p>${product.price}</p>
        </div>
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

3. Optimize with Parallel Data Fetching

// Fetch multiple data sources in parallel
async function getPageData() {
  const [posts, users, categories] = await Promise.all([
    fetch('https://api.example.com/posts').then(res => res.json()),
    fetch('https://api.example.com/users').then(res => res.json()),
    fetch('https://api.example.com/categories').then(res => res.json())
  ]);

  return { posts, users, categories };
}

export default async function HomePage() {
  const { posts, users, categories } = await getPageData();

  return (
    <div>
      <h1>Welcome</h1>
      {/* Render your data */}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Common Patterns and Examples

Pattern 1: Server-First with Client Enhancement

// Server Component for initial render
async function PostsPage() {
  const initialPosts = await getPosts();

  return (
    <div>
      <h1>Posts</h1>
      <PostsWithInteraction initialPosts={initialPosts} />
    </div>
  );
}

// Client Component for interactions
'use client';
function PostsWithInteraction({ initialPosts }) {
  const [posts, setPosts] = useState(initialPosts);
  const [filter, setFilter] = useState('all');

  const filteredPosts = posts.filter(post => 
    filter === 'all' || post.category === filter
  );

  return (
    <div>
      <select onChange={(e) => setFilter(e.target.value)}>
        <option value="all">All Categories</option>
        <option value="tech">Technology</option>
        <option value="design">Design</option>
      </select>

      {filteredPosts.map(post => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.excerpt}</p>
        </article>
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Pattern 2: API Routes for Client Components

// app/api/posts/route.js (API Route)
import { NextResponse } from 'next/server';

export async function GET(request) {
  const { searchParams } = new URL(request.url);
  const category = searchParams.get('category');

  try {
    // Fetch from database or external API
    const posts = await fetchPostsByCategory(category);
    return NextResponse.json(posts);
  } catch (error) {
    return NextResponse.json(
      { error: 'Failed to fetch posts' },
      { status: 500 }
    );
  }
}

// Client Component using the API
'use client';
export default function DynamicPosts() {
  const [posts, setPosts] = useState([]);
  const [category, setCategory] = useState('all');

  useEffect(() => {
    fetch(`/api/posts?category=${category}`)
      .then(res => res.json())
      .then(setPosts);
  }, [category]);

  return (
    <div>
      <select onChange={(e) => setCategory(e.target.value)}>
        <option value="all">All</option>
        <option value="tech">Tech</option>
      </select>
      {/* Render posts */}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Understanding data fetching in Next.js Server and Client Components is essential for building modern web applications. Server Components excel at initial page loads, SEO, and performance, while Client Components handle interactivity and real-time features.

Key takeaways:

  • Use Server Components for static content and initial data loading
  • Use Client Components for interactive features and user-driven actions
  • Combine both approaches for optimal performance and user experience
  • Implement proper error handling and loading states
  • Consider caching strategies for better performance

By mastering these patterns, you'll be able to build fast, SEO-friendly, and interactive applications that provide excellent user experiences while maintaining optimal performance.


Ready to implement data fetching in your Next.js application? Start with Server Components for your initial page loads and progressively enhance with Client Components where interactivity is needed.

Top comments (0)