Back to blog

React 19 Server Components: A Practical Guide to Master the New Era of React

Hello HaWkers, React 19 brought a fundamental change in how we build web applications. Server Components represent the biggest evolution of React since the introduction of Hooks, and understanding this concept is essential for any frontend developer in 2025.

Have you ever wondered why your React applications send so much JavaScript to the browser? Server Components arrived to solve exactly this problem, allowing components to be rendered on the server without sending unnecessary code to the client.

What Are Server Components

Server Components are React components that run exclusively on the server. Unlike traditional components that run in the browser, they are never sent to the client, resulting in smaller bundles and better performance.

React Server Components

Main Benefits

  • Zero JavaScript on client: Server components do not add to the bundle
  • Direct resource access: Database, file system, internal APIs
  • Automatic streaming: Content progressively sent to user
  • Optimized SEO: Complete HTML available for crawlers

Client Components vs Server Components

The distinction between these two types of components is fundamental for architecting modern React applications.

Server Components (Default in React 19)

// app/products/page.tsx
// This is a Server Component by default

import { db } from '@/lib/database';

interface Product {
  id: number;
  name: string;
  price: number;
  description: string;
}

async function fetchProducts(): Promise<Product[]> {
  // Direct database access - impossible in Client Components
  const products = await db.query('SELECT * FROM products ORDER BY created_at DESC');
  return products;
}

export default async function ProductsPage() {
  const products = await fetchProducts();

  return (
    <main className="container mx-auto p-4">
      <h1 className="text-3xl font-bold mb-6">Our Products</h1>

      <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
        {products.map((product) => (
          <article key={product.id} className="border rounded-lg p-4">
            <h2 className="text-xl font-semibold">{product.name}</h2>
            <p className="text-gray-600 mt-2">{product.description}</p>
            <p className="text-2xl font-bold mt-4 text-green-600">
              ${product.price.toFixed(2)}
            </p>
          </article>
        ))}
      </div>
    </main>
  );
}

Client Components (Interactivity)

// components/CartButton.tsx
'use client'; // Required directive for Client Components

import { useState } from 'react';

interface CartButtonProps {
  productId: number;
  name: string;
  price: number;
}

export function CartButton({ productId, name, price }: CartButtonProps) {
  const [quantity, setQuantity] = useState(1);
  const [added, setAdded] = useState(false);

  const handleAdd = async () => {
    try {
      await fetch('/api/cart', {
        method: 'POST',
        body: JSON.stringify({ productId, quantity }),
        headers: { 'Content-Type': 'application/json' }
      });

      setAdded(true);
      setTimeout(() => setAdded(false), 2000);
    } catch (error) {
      console.error('Error adding to cart:', error);
    }
  };

  return (
    <div className="flex items-center gap-4 mt-4">
      <div className="flex items-center border rounded">
        <button
          onClick={() => setQuantity(Math.max(1, quantity - 1))}
          className="px-3 py-1 hover:bg-gray-100"
        >
          -
        </button>
        <span className="px-4">{quantity}</span>
        <button
          onClick={() => setQuantity(quantity + 1)}
          className="px-3 py-1 hover:bg-gray-100"
        >
          +
        </button>
      </div>

      <button
        onClick={handleAdd}
        disabled={added}
        className={`px-6 py-2 rounded font-medium transition-colors ${
          added
            ? 'bg-green-500 text-white'
            : 'bg-blue-600 text-white hover:bg-blue-700'
        }`}
      >
        {added ? 'Added!' : 'Add to Cart'}
      </button>
    </div>
  );
}

Composition: Combining Server and Client Components

The real magic happens when we strategically combine both types of components.

// app/products/[id]/page.tsx
// Server Component - fetches data

import { db } from '@/lib/database';
import { CartButton } from '@/components/CartButton';
import { ImageGallery } from '@/components/ImageGallery';
import { CustomerReviews } from '@/components/CustomerReviews';

interface PageProps {
  params: { id: string };
}

async function fetchProduct(id: string) {
  const product = await db.query(
    'SELECT * FROM products WHERE id = ?',
    [id]
  );
  return product[0];
}

async function fetchReviews(productId: string) {
  return await db.query(
    'SELECT * FROM reviews WHERE product_id = ? ORDER BY date DESC LIMIT 10',
    [productId]
  );
}

export default async function ProductPage({ params }: PageProps) {
  // Parallel fetches on the server
  const [product, reviews] = await Promise.all([
    fetchProduct(params.id),
    fetchReviews(params.id)
  ]);

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

  return (
    <main className="container mx-auto p-4">
      <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
        {/* Client Component for gallery interactivity */}
        <ImageGallery images={product.images} />

        <div>
          <h1 className="text-3xl font-bold">{product.name}</h1>
          <p className="text-gray-600 mt-4">{product.description}</p>

          <div className="mt-6">
            <span className="text-4xl font-bold text-green-600">
              ${product.price.toFixed(2)}
            </span>
          </div>

          {/* Client Component to add to cart */}
          <CartButton
            productId={product.id}
            name={product.name}
            price={product.price}
          />
        </div>
      </div>

      {/* Server Component rendered with server data */}
      <section className="mt-12">
        <h2 className="text-2xl font-bold mb-6">Customer Reviews</h2>
        <CustomerReviews reviews={reviews} />
      </section>
    </main>
  );
}

ImageGallery as Client Component

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

import { useState } from 'react';
import Image from 'next/image';

interface ImageGalleryProps {
  images: string[];
}

export function ImageGallery({ images }: ImageGalleryProps) {
  const [activeImage, setActiveImage] = useState(0);

  return (
    <div className="space-y-4">
      <div className="relative aspect-square bg-gray-100 rounded-lg overflow-hidden">
        <Image
          src={images[activeImage]}
          alt="Product image"
          fill
          className="object-cover"
          priority
        />
      </div>

      <div className="flex gap-2 overflow-x-auto">
        {images.map((img, index) => (
          <button
            key={index}
            onClick={() => setActiveImage(index)}
            className={`relative w-20 h-20 rounded border-2 overflow-hidden flex-shrink-0 ${
              index === activeImage ? 'border-blue-500' : 'border-transparent'
            }`}
          >
            <Image src={img} alt="" fill className="object-cover" />
          </button>
        ))}
      </div>
    </div>
  );
}

Streaming and Suspense

One of the great advantages of Server Components is content streaming with Suspense, allowing parts of the page to be displayed while others are still loading.

// app/dashboard/page.tsx
import { Suspense } from 'react';
import { GeneralStats } from '@/components/GeneralStats';
import { SalesChart } from '@/components/SalesChart';
import { OrdersTable } from '@/components/OrdersTable';
import { LoadingSpinner } from '@/components/LoadingSpinner';

export default function Dashboard() {
  return (
    <main className="p-6 space-y-6">
      <h1 className="text-3xl font-bold">Dashboard</h1>

      {/* Stats load first - fast */}
      <Suspense fallback={<LoadingSpinner text="Loading stats..." />}>
        <GeneralStats />
      </Suspense>

      <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
        {/* Chart may take longer */}
        <Suspense fallback={<LoadingSpinner text="Loading chart..." />}>
          <SalesChart />
        </Suspense>

        {/* Table independent of chart */}
        <Suspense fallback={<LoadingSpinner text="Loading orders..." />}>
          <OrdersTable />
        </Suspense>
      </div>
    </main>
  );
}

// components/GeneralStats.tsx
// Server Component - runs on server

import { db } from '@/lib/database';

async function fetchStats() {
  const [sales, customers, orders] = await Promise.all([
    db.query('SELECT SUM(value) as total FROM sales WHERE month = CURRENT_MONTH'),
    db.query('SELECT COUNT(*) as total FROM customers WHERE active = true'),
    db.query('SELECT COUNT(*) as total FROM orders WHERE status = "pending"')
  ]);

  return {
    monthlySales: sales[0].total,
    activeCustomers: customers[0].total,
    pendingOrders: orders[0].total
  };
}

export async function GeneralStats() {
  const stats = await fetchStats();

  return (
    <div className="grid grid-cols-3 gap-4">
      <div className="bg-white p-6 rounded-lg shadow">
        <h3 className="text-gray-500 text-sm">Monthly Sales</h3>
        <p className="text-3xl font-bold text-green-600">
          ${stats.monthlySales.toLocaleString()}
        </p>
      </div>

      <div className="bg-white p-6 rounded-lg shadow">
        <h3 className="text-gray-500 text-sm">Active Customers</h3>
        <p className="text-3xl font-bold text-blue-600">
          {stats.activeCustomers.toLocaleString()}
        </p>
      </div>

      <div className="bg-white p-6 rounded-lg shadow">
        <h3 className="text-gray-500 text-sm">Pending Orders</h3>
        <p className="text-3xl font-bold text-orange-600">
          {stats.pendingOrders}
        </p>
      </div>
    </div>
  );
}

Server Actions: Server-Side Mutations

Server Actions allow executing functions on the server directly from components, drastically simplifying form handling.

// app/contact/page.tsx
import { sendMessage } from './actions';

export default function ContactPage() {
  return (
    <main className="container mx-auto p-4 max-w-lg">
      <h1 className="text-3xl font-bold mb-6">Get in Touch</h1>

      <form action={sendMessage} className="space-y-4">
        <div>
          <label htmlFor="name" className="block text-sm font-medium mb-1">
            Name
          </label>
          <input
            type="text"
            id="name"
            name="name"
            required
            className="w-full border rounded-lg px-4 py-2"
          />
        </div>

        <div>
          <label htmlFor="email" className="block text-sm font-medium mb-1">
            Email
          </label>
          <input
            type="email"
            id="email"
            name="email"
            required
            className="w-full border rounded-lg px-4 py-2"
          />
        </div>

        <div>
          <label htmlFor="message" className="block text-sm font-medium mb-1">
            Message
          </label>
          <textarea
            id="message"
            name="message"
            rows={5}
            required
            className="w-full border rounded-lg px-4 py-2"
          />
        </div>

        <SubmitButton />
      </form>
    </main>
  );
}

// Button with loading state
'use client';

import { useFormStatus } from 'react-dom';

function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    <button
      type="submit"
      disabled={pending}
      className="w-full bg-blue-600 text-white py-3 rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50"
    >
      {pending ? 'Sending...' : 'Send Message'}
    </button>
  );
}
// app/contact/actions.ts
'use server';

import { db } from '@/lib/database';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';

export async function sendMessage(formData: FormData) {
  const name = formData.get('name') as string;
  const email = formData.get('email') as string;
  const message = formData.get('message') as string;

  // Server-side validation
  if (!name || !email || !message) {
    throw new Error('All fields are required');
  }

  if (!email.includes('@')) {
    throw new Error('Invalid email');
  }

  // Save to database
  await db.query(
    'INSERT INTO messages (name, email, message, created_at) VALUES (?, ?, ?, NOW())',
    [name, email, message]
  );

  // Send notification email
  await fetch(process.env.EMAIL_API_URL!, {
    method: 'POST',
    body: JSON.stringify({
      to: process.env.ADMIN_EMAIL,
      subject: `New message from ${name}`,
      body: message
    })
  });

  // Revalidate cache and redirect
  revalidatePath('/contact');
  redirect('/contact/success');
}

Cache and Revalidation Patterns

Understanding the cache system of React 19 and Next.js is crucial for performant applications.

// lib/data.ts

// Time-based cache - revalidates every hour
export async function fetchCategories() {
  const response = await fetch('https://api.example.com/categories', {
    next: { revalidate: 3600 } // 1 hour in seconds
  });
  return response.json();
}

// Static cache - revalidates only on build
export async function fetchStaticPages() {
  const response = await fetch('https://api.example.com/pages', {
    cache: 'force-cache'
  });
  return response.json();
}

// No cache - always fetches fresh data
export async function fetchNotifications() {
  const response = await fetch('https://api.example.com/notifications', {
    cache: 'no-store'
  });
  return response.json();
}

// Cache with tags for selective invalidation
export async function fetchProductsByCategory(categoryId: string) {
  const response = await fetch(
    `https://api.example.com/categories/${categoryId}/products`,
    {
      next: {
        revalidate: 300, // 5 minutes
        tags: [`category-${categoryId}`, 'products']
      }
    }
  );
  return response.json();
}
// app/admin/products/actions.ts
'use server';

import { revalidateTag, revalidatePath } from 'next/cache';

export async function updateProduct(productId: string, data: FormData) {
  await db.query('UPDATE products SET ... WHERE id = ?', [productId]);

  // Invalidate specific cache
  revalidateTag(`product-${productId}`);
  revalidateTag('products');

  // Or invalidate by path
  revalidatePath('/products');
  revalidatePath(`/products/${productId}`);
}

export async function deleteProduct(productId: string) {
  await db.query('DELETE FROM products WHERE id = ?', [productId]);

  // Invalidate all product-related caches
  revalidateTag('products');
  revalidatePath('/products');
}

Common Mistakes and How to Avoid Them

Some errors are frequent when working with Server Components for the first time.

Mistake 1: Using Hooks in Server Components

// WRONG - hooks do not work in Server Components
export default async function Page() {
  const [state, setState] = useState(''); // Error!
  // ...
}

// CORRECT - extract to Client Component
'use client';
export function InteractiveComponent() {
  const [state, setState] = useState('');
  // ...
}

Mistake 2: Passing Functions as Props to Client Components

// WRONG - functions are not serializable
export default function ServerComponent() {
  const handleClick = () => console.log('click');

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

// CORRECT - use Server Actions
// actions.ts
'use server';
export async function handleSubmit(formData: FormData) {
  // ...
}

// page.tsx
import { handleSubmit } from './actions';

export default function ServerComponent() {
  return (
    <form action={handleSubmit}>
      {/* ... */}
    </form>
  );
}

If you want to master more advanced frontend techniques, I recommend checking out the article Modern CSS in 2025 where we explore news that perfectly complements React.

Let us go! 🦅

Comments (0)

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

Add comments