Back to blog

React 19 Server Components: Practical Guide For Developers

Hello HaWkers, React 19 brought Server Components as a stable feature, fundamentally changing how we think about rendering in React. If you still haven't fully understood how they work or when to use them, this practical guide will clarify everything.

Have you ever wondered why your JavaScript bundles are so large or why your application's initial performance is slow? Server Components might be the answer.

What Are Server Components

Server Components are React components that execute exclusively on the server. Unlike traditional SSR, they never send JavaScript to the client.

Fundamental Difference

Approach comparison:

Aspect Client Components Server Components
Where it executes Browser Server
Client JavaScript Yes No
Database access Via API Direct
State and events useState, onClick Not available
Initial performance Slower Faster

💡 Context: Server Components don't replace Client Components. They work together, each doing what they do best.

How They Work in Practice

Let's see concrete examples of Server and Client Components:

Basic Server Component

// app/posts/page.tsx (Server Component by default)
import { db } from '@/lib/database';

// This component NEVER goes to the browser
async function PostsPage() {
  // Direct database access
  const posts = await db.posts.findMany({
    orderBy: { createdAt: 'desc' },
    take: 10,
  });

  return (
    <div className="posts-container">
      <h1>Latest Posts</h1>
      {posts.map(post => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.excerpt}</p>
          <time>{new Date(post.createdAt).toLocaleDateString()}</time>
        </article>
      ))}
    </div>
  );
}

export default PostsPage;

Client Component

// components/LikeButton.tsx
'use client'; // Required marker

import { useState, useTransition } from 'react';
import { likePost } from '@/actions/posts';

interface LikeButtonProps {
  postId: string;
  initialLikes: number;
}

export function LikeButton({ postId, initialLikes }: LikeButtonProps) {
  const [likes, setLikes] = useState(initialLikes);
  const [isPending, startTransition] = useTransition();

  const handleLike = () => {
    startTransition(async () => {
      const newLikes = await likePost(postId);
      setLikes(newLikes);
    });
  };

  return (
    <button
      onClick={handleLike}
      disabled={isPending}
      className="like-button"
    >
      {isPending ? '...' : `❤️ ${likes}`}
    </button>
  );
}

Combining Server and Client

// app/posts/[id]/page.tsx
import { db } from '@/lib/database';
import { LikeButton } from '@/components/LikeButton';
import { CommentSection } from '@/components/CommentSection';

async function PostPage({ params }: { params: { id: string } }) {
  const post = await db.posts.findUnique({
    where: { id: params.id },
    include: { author: true, comments: true },
  });

  if (!post) {
    return <div>Post not found</div>;
  }

  return (
    <article>
      <header>
        <h1>{post.title}</h1>
        <span>By {post.author.name}</span>
      </header>

      <div className="content">
        {post.content}
      </div>

      {/* Client Component with interactivity */}
      <LikeButton postId={post.id} initialLikes={post.likes} />

      {/* Another Client Component */}
      <CommentSection
        postId={post.id}
        initialComments={post.comments}
      />
    </article>
  );
}

export default PostPage;

Composition Patterns

The key to using Server Components effectively is understanding composition patterns:

1. Passing Server Components as Children

// components/ClientWrapper.tsx
'use client';

import { useState } from 'react';

interface ClientWrapperProps {
  children: React.ReactNode;
}

export function ClientWrapper({ children }: ClientWrapperProps) {
  const [isExpanded, setIsExpanded] = useState(false);

  return (
    <div className={isExpanded ? 'expanded' : 'collapsed'}>
      <button onClick={() => setIsExpanded(!isExpanded)}>
        {isExpanded ? 'Collapse' : 'Expand'}
      </button>
      {isExpanded && children}
    </div>
  );
}

// app/page.tsx (Server Component)
import { ClientWrapper } from '@/components/ClientWrapper';
import { ExpensiveServerComponent } from '@/components/ExpensiveServerComponent';

export default function Page() {
  return (
    <ClientWrapper>
      {/* This Server Component is passed as children */}
      <ExpensiveServerComponent />
    </ClientWrapper>
  );
}

2. Slots Pattern

// components/Layout.tsx
'use client';

import { useState } from 'react';

interface LayoutProps {
  header: React.ReactNode;
  sidebar: React.ReactNode;
  content: React.ReactNode;
}

export function Layout({ header, sidebar, content }: LayoutProps) {
  const [sidebarOpen, setSidebarOpen] = useState(true);

  return (
    <div className="layout">
      <header>{header}</header>
      <button onClick={() => setSidebarOpen(!sidebarOpen)}>
        Toggle Sidebar
      </button>
      {sidebarOpen && <aside>{sidebar}</aside>}
      <main>{content}</main>
    </div>
  );
}

// app/dashboard/page.tsx
import { Layout } from '@/components/Layout';
import { UserStats } from '@/components/UserStats';
import { Navigation } from '@/components/Navigation';
import { DashboardContent } from '@/components/DashboardContent';

export default function DashboardPage() {
  return (
    <Layout
      header={<Navigation />}
      sidebar={<UserStats />}
      content={<DashboardContent />}
    />
  );
}

Server Actions

React 19 introduced Server Actions for integrated mutations:

Creating Server Actions

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

import { db } from '@/lib/database';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';

const PostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(10),
});

export async function createPost(formData: FormData) {
  const rawData = {
    title: formData.get('title'),
    content: formData.get('content'),
  };

  const validatedData = PostSchema.safeParse(rawData);

  if (!validatedData.success) {
    return { error: validatedData.error.flatten().fieldErrors };
  }

  try {
    const post = await db.posts.create({
      data: validatedData.data,
    });

    revalidatePath('/posts');
    return { success: true, postId: post.id };
  } catch (error) {
    return { error: 'Failed to create post' };
  }
}

export async function likePost(postId: string) {
  const post = await db.posts.update({
    where: { id: postId },
    data: { likes: { increment: 1 } },
  });

  revalidatePath(`/posts/${postId}`);
  return post.likes;
}

export async function deletePost(postId: string) {
  await db.posts.delete({ where: { id: postId } });
  revalidatePath('/posts');
}

Using Server Actions in Forms

// components/CreatePostForm.tsx
'use client';

import { useActionState } from 'react';
import { createPost } from '@/actions/posts';

export function CreatePostForm() {
  const [state, formAction, isPending] = useActionState(createPost, null);

  return (
    <form action={formAction}>
      <div>
        <label htmlFor="title">Title</label>
        <input
          id="title"
          name="title"
          type="text"
          required
          disabled={isPending}
        />
        {state?.error?.title && (
          <span className="error">{state.error.title}</span>
        )}
      </div>

      <div>
        <label htmlFor="content">Content</label>
        <textarea
          id="content"
          name="content"
          rows={10}
          required
          disabled={isPending}
        />
        {state?.error?.content && (
          <span className="error">{state.error.content}</span>
        )}
      </div>

      <button type="submit" disabled={isPending}>
        {isPending ? 'Creating...' : 'Create Post'}
      </button>

      {state?.success && (
        <div className="success">Post created successfully!</div>
      )}
    </form>
  );
}

Optimization and Performance

Server Components bring significant performance benefits:

1. Streaming with Suspense

// app/dashboard/page.tsx
import { Suspense } from 'react';
import { SlowDataComponent } from '@/components/SlowDataComponent';
import { FastDataComponent } from '@/components/FastDataComponent';

export default function DashboardPage() {
  return (
    <div className="dashboard">
      <h1>Dashboard</h1>

      {/* Fast component renders immediately */}
      <FastDataComponent />

      {/* Slow component loads via streaming */}
      <Suspense fallback={<div>Loading data...</div>}>
        <SlowDataComponent />
      </Suspense>

      {/* Multiple Suspense for parallel streaming */}
      <div className="widgets">
        <Suspense fallback={<WidgetSkeleton />}>
          <AnalyticsWidget />
        </Suspense>

        <Suspense fallback={<WidgetSkeleton />}>
          <RevenueWidget />
        </Suspense>

        <Suspense fallback={<WidgetSkeleton />}>
          <UsersWidget />
        </Suspense>
      </div>
    </div>
  );
}

2. Data Preloading

// lib/preload.ts
import { cache } from 'react';

export const getUser = cache(async (userId: string) => {
  const user = await db.users.findUnique({ where: { id: userId } });
  return user;
});

export const preloadUser = (userId: string) => {
  void getUser(userId);
};

// components/UserProfile.tsx
import { getUser, preloadUser } from '@/lib/preload';

// In a parent component
function ParentComponent({ userId }: { userId: string }) {
  // Start fetch early
  preloadUser(userId);

  return (
    <Suspense fallback={<ProfileSkeleton />}>
      <UserProfile userId={userId} />
    </Suspense>
  );
}

// Child component uses preloaded data
async function UserProfile({ userId }: { userId: string }) {
  const user = await getUser(userId); // Already cached!

  return (
    <div className="profile">
      <img src={user.avatar} alt={user.name} />
      <h2>{user.name}</h2>
      <p>{user.bio}</p>
    </div>
  );
}

When to Use Each Type

Practical guide for deciding between Server and Client Components:

Use Server Components For

Ideal cases:

  • Database data fetching
  • Private API access
  • Components without interactivity
  • Static or semi-static content
  • Bundle size reduction
// Server Component examples
// - Data listings
// - Headers and footers
// - Static navigation
// - Page content
// - Layout components

async function ProductList() {
  const products = await db.products.findMany();

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

Use Client Components For

Ideal cases:

  • Interactivity (onClick, onChange)
  • Local state (useState)
  • Effects (useEffect)
  • Browser APIs (localStorage, geolocation)
  • Client-only libraries
// Client Component examples
// - Forms
// - Modals and dialogs
// - Carousels and sliders
// - Tooltips and dropdowns
// - Components with animation

'use client';

function SearchInput() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  useEffect(() => {
    const search = async () => {
      if (query.length > 2) {
        const data = await fetch(`/api/search?q=${query}`);
        setResults(await data.json());
      }
    };
    const timer = setTimeout(search, 300);
    return () => clearTimeout(timer);
  }, [query]);

  return (
    <div>
      <input
        value={query}
        onChange={e => setQuery(e.target.value)}
        placeholder="Search..."
      />
      <ul>
        {results.map(r => <li key={r.id}>{r.title}</li>)}
      </ul>
    </div>
  );
}

Common Mistakes and Solutions

Avoid these frequent problems:

1. Importing Client Component without 'use client'

// ❌ Wrong: using hooks without 'use client'
import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0); // Error!
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

// ✅ Correct: add 'use client'
'use client';

import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

2. Passing Non-Serializable Functions

// ❌ Wrong: passing function from Server to Client
async function ServerComponent() {
  const handleClick = () => console.log('clicked'); // Not serializable!

  return <ClientButton onClick={handleClick} />; // Error!
}

// ✅ Correct: use Server Action
async function ServerComponent() {
  return <ClientButton postId="123" />;
}

// Client Component calls the action
'use client';
import { likePost } from '@/actions/posts';

function ClientButton({ postId }: { postId: string }) {
  return <button onClick={() => likePost(postId)}>Like</button>;
}

3. Fetching in Client Component Unnecessarily

// ❌ Wrong: fetching on client when could be on server
'use client';

function UserList() {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    fetch('/api/users').then(r => r.json()).then(setUsers);
  }, []);

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

// ✅ Correct: Server Component with direct access
async function UserList() {
  const users = await db.users.findMany();

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

Migrating to Server Components

If you have an existing React application:

Migration Strategy

Recommended steps:

  1. Identify components without interactivity
  2. Move data fetching to Server Components
  3. Add 'use client' where needed
  4. Refactor to composition patterns
  5. Implement Server Actions for mutations

Migration Example

// Before: everything on client
'use client';

function ProductPage({ productId }) {
  const [product, setProduct] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch(`/api/products/${productId}`)
      .then(r => r.json())
      .then(data => {
        setProduct(data);
        setLoading(false);
      });
  }, [productId]);

  if (loading) return <div>Loading...</div>;

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <AddToCartButton productId={productId} />
    </div>
  );
}

// After: Server Component with Client Component for interactivity
// app/products/[id]/page.tsx (Server Component)
async function ProductPage({ params }) {
  const product = await db.products.findUnique({
    where: { id: params.id }
  });

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <AddToCartButton productId={params.id} />
    </div>
  );
}

// components/AddToCartButton.tsx (Client Component)
'use client';

function AddToCartButton({ productId }) {
  const [added, setAdded] = useState(false);

  return (
    <button onClick={() => {
      addToCart(productId);
      setAdded(true);
    }}>
      {added ? 'Added!' : 'Add to Cart'}
    </button>
  );
}

Conclusion

React 19 Server Components represent a fundamental change in how we build React applications. By executing components on the server and sending only HTML to the client, we achieve faster applications, smaller bundles, and direct access to server resources.

The key is understanding that Server and Client Components work together. Use Server Components to fetch data and render static content, and Client Components for interactivity. With this clear division, your applications will be more performant and easier to maintain.

If you want to dive deeper into React and modern frontend development, I recommend checking out another article: Svelte 5 and Runes: Why the Framework Is Gaining Ground where you'll discover interesting alternatives to React.

Let's go! 🦅

💻 Master JavaScript for Real

The knowledge you gained in this article is just the beginning. There are techniques, patterns, and practices that transform beginner developers into sought-after professionals.

Invest in Your Future

I've prepared complete material for you to master JavaScript:

Payment options:

  • 1x of $4.90 no interest
  • or $4.90 at sight

📖 View Complete Content

Comments (0)

This article has no comments yet 😢. Be the first! 🚀🦅

Add comments