Skip to main content
Back to blog

React Server Components: a practical guide with Next.js

Ray MartínRay Martín
9 min read
React Server Components: a practical guide with Next.js

What Are Server Components and Why They Matter

React Server Components (RSC) represent the most significant shift in React architecture since the introduction of hooks. They are components that run exclusively on the server: their code is never sent to the browser, which means zero kilobytes of JavaScript in the client bundle for those components.

In a traditional React model, all of your component code — including data transformation logic, formatting libraries, and validations — is sent to the client even though it only runs once during the initial render. With Server Components, that code stays on the server and only the resulting HTML reaches the browser.

typescript
// This component is a Server Component by default in Next.js 15
// Its code is NEVER sent to the browser
import { formatDistance } from "date-fns";
import { enUS } from "date-fns/locale";

interface Article {
  id: string;
  title: string;
  content: string;
  createdAt: Date;
}

export default async function ArticlePage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;

  // Fetch directly in the component — no useEffect, no useState
  const article: Article = await fetch(
    `https://api.example.com/articles/${id}`,
    { next: { revalidate: 3600 } }
  ).then((res) => res.json());

  const timeAgo = formatDistance(new Date(article.createdAt), new Date(), {
    addSuffix: true,
    locale: enUS,
  });

  return (
    <article className="prose prose-lg max-w-3xl mx-auto">
      <h1>{article.title}</h1>
      <time className="text-gray-500">{timeAgo}</time>
      <div dangerouslySetInnerHTML={{ __html: article.content }} />
    </article>
  );
}

In this example, the date-fns library (which weighs over 70KB) is never included in the client bundle. The server performs the formatting, generates the HTML, and sends only the result. The user receives a lighter, faster page.

Server vs Client Components: When to Use Each

The decision between a Server and Client Component depends on what the component needs to do. Here is the general rule:

  • Server Component (default in Next.js 15): For everything that does not require user interactivity or browser APIs.
  • Client Component (with "use client"): For interactivity, state, effects, and browser APIs.

When to Use Server Components

  1. Data fetching: Direct access to databases, APIs, or file systems.
  2. Content rendering: Markdown, HTML, static lists.
  3. Heavy business logic: Data transformations, calculations, formatting.
  4. Accessing secrets: Server environment variables, API keys, tokens.
  5. Layout components: Headers, footers, sidebars without interactivity.

When to Use Client Components

  1. Interactivity: onClick, onChange, onSubmit and other event handlers.
  2. Local state: useState, useReducer.
  3. Effects: useEffect, useLayoutEffect.
  4. Browser APIs: localStorage, window, navigator, IntersectionObserver.
  5. Custom hooks: Any hook that uses state or effects.
  6. Third-party libraries with state: Forms (react-hook-form), animations (framer-motion).
typescript
// Quick decision table
// ┌─────────────────────────────┬────────┬────────┐
// │ Requirement                 │ Server │ Client │
// ├─────────────────────────────┼────────┼────────┤
// │ Data fetching               │   ✓    │        │
// │ Direct backend access       │   ✓    │        │
// │ Server secrets              │   ✓    │        │
// │ Heavy dependencies          │   ✓    │        │
// │ onClick / onChange          │        │   ✓    │
// │ useState / useReducer       │        │   ✓    │
// │ useEffect                   │        │   ✓    │
// │ Browser APIs                │        │   ✓    │
// │ Context providers           │        │   ✓    │
// │ Class-based components      │        │   ✓    │
// └─────────────────────────────┴────────┴────────┘

The "use client" Directive and Its Boundaries

The "use client" directive is placed at the top of a file to mark that module (and everything it imports) as code that runs on the client. It is a boundary: everything below that directive in the import tree becomes part of the client bundle.

typescript
// components/Counter.tsx
"use client"; // This line marks the server-client boundary

import { useState } from "react";

export function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div className="flex items-center gap-4">
      <button
        onClick={() => setCount((c) => c - 1)}
        className="rounded-lg bg-gray-200 px-4 py-2 hover:bg-gray-300"
        aria-label="Decrement"
      >
        -
      </button>
      <span className="text-2xl font-bold tabular-nums">{count}</span>
      <button
        onClick={() => setCount((c) => c + 1)}
        className="rounded-lg bg-primary-600 px-4 py-2 text-white hover:bg-primary-700"
        aria-label="Increment"
      >
        +
      </button>
    </div>
  );
}

Important points about the directive:

  • It is a module boundary: It affects the file where it is declared and all modules that file imports. You cannot have a Server Component importing directly from a file marked with "use client" and expect it to run on the server.
  • It does not infect upward: A Server Component can render a Client Component as a child. The directive only affects downward in the import tree.
  • It must be the first statement: The directive must be literally the first line of the file (excluding comments).

Common mistake: Placing "use client" in a layout file or in a high-level parent component. This converts the entire branch into Client Components, losing all the benefits of RSC. The directive should be placed as low as possible in the component tree.

Data Fetching in Server Components

One of the most powerful advantages of Server Components is that they can be async functions. You can use await directly in the component without needing useEffect, useState, or any data fetching libraries.

typescript
// app/[locale]/projects/page.tsx
// This component is async — it runs on the server
interface Project {
  id: string;
  title: string;
  description: string;
  stack: string[];
  url: string;
}

async function getProjects(): Promise<Project[]> {
  const res = await fetch("https://api.example.com/projects", {
    next: { revalidate: 3600 }, // Revalidate every hour
  });

  if (!res.ok) throw new Error("Failed to fetch projects");
  return res.json();
}

export default async function ProjectsPage() {
  const projects = await getProjects();

  return (
    <main className="container mx-auto px-4 py-12">
      <h1 className="text-4xl font-bold mb-8">Projects</h1>
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
        {projects.map((project) => (
          <article
            key={project.id}
            className="rounded-xl border border-gray-200 p-6 hover:shadow-lg transition-shadow"
          >
            <h2 className="text-xl font-semibold">{project.title}</h2>
            <p className="mt-2 text-gray-600">{project.description}</p>
            <div className="mt-4 flex flex-wrap gap-2">
              {project.stack.map((tech) => (
                <span
                  key={tech}
                  className="rounded-full bg-primary-100 px-3 py-1 text-xs font-medium text-primary-800"
                >
                  {tech}
                </span>
              ))}
            </div>
          </article>
        ))}
      </div>
    </main>
  );
}

Direct Database Access

Server Components can access databases directly without exposing credentials or creating intermediate API endpoints:

typescript
// app/[locale]/blog/[slug]/page.tsx
import { db } from "@/lib/database";
import { notFound } from "next/navigation";

export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;

  const post = await db.post.findUnique({
    where: { slug, published: true },
    include: {
      author: { select: { name: true, avatar: true } },
      tags: true,
    },
  });

  if (!post) notFound();

  return (
    <article className="prose prose-lg max-w-3xl mx-auto py-12">
      <header>
        <h1>{post.title}</h1>
        <div className="flex items-center gap-3 not-prose">
          <img
            src={post.author.avatar}
            alt={post.author.name}
            className="h-10 w-10 rounded-full"
          />
          <span className="text-gray-600">{post.author.name}</span>
        </div>
      </header>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

Composition Patterns: Server Parent with Client Children

The most powerful RSC pattern is composition: a Server Component can render Client Components as children, passing data to them through props. This allows you to keep fetching logic on the server while interactivity is handled on the client.

typescript
// app/[locale]/dashboard/page.tsx (Server Component)
import { db } from "@/lib/database";
import { DashboardChart } from "@/components/dashboard/DashboardChart";
import { DashboardFilters } from "@/components/dashboard/DashboardFilters";
import { StatsCards } from "@/components/dashboard/StatsCards";

export default async function DashboardPage() {
  // Fetch data on the server
  const [stats, chartData, recentActivity] = await Promise.all([
    db.analytics.getStats(),
    db.analytics.getChartData({ period: "30d" }),
    db.activity.getRecent({ limit: 10 }),
  ]);

  return (
    <main className="container mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-8">Dashboard</h1>

      {/* StatsCards is a Server Component — just HTML */}
      <StatsCards stats={stats} />

      {/* DashboardFilters is a Client Component — has state */}
      <DashboardFilters />

      {/* DashboardChart is a Client Component — uses a charting library */}
      <DashboardChart data={chartData} />

      {/* The list is a Server Component — no interactivity */}
      <section className="mt-8">
        <h2 className="text-xl font-semibold mb-4">Recent Activity</h2>
        <ul className="space-y-3">
          {recentActivity.map((item) => (
            <li key={item.id} className="rounded-lg border p-4">
              <p className="font-medium">{item.description}</p>
              <time className="text-sm text-gray-500">{item.timestamp}</time>
            </li>
          ))}
        </ul>
      </section>
    </main>
  );
}
typescript
// components/dashboard/DashboardChart.tsx
"use client";

import { useState } from "react";

interface ChartDataPoint {
  date: string;
  value: number;
}

interface DashboardChartProps {
  data: ChartDataPoint[];
}

export function DashboardChart({ data }: DashboardChartProps) {
  const [period, setPeriod] = useState<"7d" | "30d" | "90d">("30d");

  // Initial data comes from the server via props
  // Local state handles client-side interactivity
  const filteredData = data.filter((point) => {
    const daysAgo = period === "7d" ? 7 : period === "30d" ? 30 : 90;
    const cutoff = new Date();
    cutoff.setDate(cutoff.getDate() - daysAgo);
    return new Date(point.date) >= cutoff;
  });

  return (
    <div className="rounded-xl border p-6">
      <div className="flex gap-2 mb-4">
        {(["7d", "30d", "90d"] as const).map((p) => (
          <button
            key={p}
            onClick={() => setPeriod(p)}
            className={
              period === p
                ? "bg-primary-600 text-white rounded-lg px-3 py-1"
                : "bg-gray-100 rounded-lg px-3 py-1 hover:bg-gray-200"
            }
          >
            {p}
          </button>
        ))}
      </div>
      {/* Render the chart with filtered data */}
      <div className="h-64 flex items-end gap-1">
        {filteredData.map((point) => (
          <div
            key={point.date}
            className="bg-primary-500 rounded-t flex-1 min-w-[4px]"
            style={{ height: `${(point.value / Math.max(...filteredData.map(d => d.value))) * 100}%` }}
            title={`${point.date}: ${point.value}`}
          />
        ))}
      </div>
    </div>
  );
}

Passing Server Data to Client Components

Data passed from Server to Client Components must be serializable: strings, numbers, booleans, arrays, plain objects, and null. You cannot pass functions, Dates, Maps, Sets, or class instances.

typescript
// CORRECT: Serialize data before passing it
// app/[locale]/users/page.tsx (Server Component)
export default async function UsersPage() {
  const users = await db.user.findMany({
    select: {
      id: true,
      name: true,
      email: true,
      createdAt: true,
    },
  });

  // Serialize dates to ISO strings before passing to client
  const serializedUsers = users.map((user) => ({
    ...user,
    createdAt: user.createdAt.toISOString(),
  }));

  return <UserTable users={serializedUsers} />;
}

// WRONG: Passing non-serializable data
// This would cause a runtime error:
// <UserTable
//   users={users}        // Date objects are not serializable
//   onDelete={deleteUser} // Functions are not serializable
// />
typescript
// components/UserTable.tsx
"use client";

import { useState } from "react";

interface SerializedUser {
  id: string;
  name: string;
  email: string;
  createdAt: string; // ISO string, not Date
}

export function UserTable({ users }: { users: SerializedUser[] }) {
  const [sortBy, setSortBy] = useState<"name" | "createdAt">("name");

  const sorted = [...users].sort((a, b) =>
    sortBy === "name"
      ? a.name.localeCompare(b.name)
      : new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
  );

  return (
    <table className="w-full border-collapse">
      <thead>
        <tr className="border-b">
          <th
            className="p-3 text-left cursor-pointer hover:text-primary-600"
            onClick={() => setSortBy("name")}
          >
            Name {sortBy === "name" && "↓"}
          </th>
          <th className="p-3 text-left">Email</th>
          <th
            className="p-3 text-left cursor-pointer hover:text-primary-600"
            onClick={() => setSortBy("createdAt")}
          >
            Date {sortBy === "createdAt" && "↓"}
          </th>
        </tr>
      </thead>
      <tbody>
        {sorted.map((user) => (
          <tr key={user.id} className="border-b hover:bg-gray-50">
            <td className="p-3">{user.name}</td>
            <td className="p-3 text-gray-600">{user.email}</td>
            <td className="p-3 text-gray-500">
              {new Date(user.createdAt).toLocaleDateString("en-US")}
            </td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

Streaming with Suspense and loading.tsx

Streaming allows you to send parts of the page to the browser as they become ready, instead of waiting for all the content to be generated on the server. This significantly improves the Time to First Byte (TTFB) metric and the perceived user experience.

The loading.tsx File

Next.js automatically uses a loading.tsx file as a Suspense fallback for the route where it is placed:

typescript
// app/[locale]/dashboard/loading.tsx
export default function DashboardLoading() {
  return (
    <div className="container mx-auto px-4 py-8 animate-pulse">
      <div className="h-10 w-48 bg-gray-200 rounded mb-8" />

      {/* Stats cards skeleton */}
      <div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
        {Array.from({ length: 4 }).map((_, i) => (
          <div key={i} className="h-24 bg-gray-200 rounded-xl" />
        ))}
      </div>

      {/* Chart skeleton */}
      <div className="h-80 bg-gray-200 rounded-xl mb-8" />

      {/* List skeleton */}
      <div className="space-y-3">
        {Array.from({ length: 5 }).map((_, i) => (
          <div key={i} className="h-16 bg-gray-200 rounded-lg" />
        ))}
      </div>
    </div>
  );
}

Granular Suspense

For finer control, wrap individual components with Suspense so that each section loads independently:

typescript
// app/[locale]/dashboard/page.tsx
import { Suspense } from "react";
import { StatsCards, StatsCardsSkeleton } from "@/components/dashboard/StatsCards";
import { RecentActivity, ActivitySkeleton } from "@/components/dashboard/RecentActivity";
import { DashboardChart, ChartSkeleton } from "@/components/dashboard/DashboardChart";

export default function DashboardPage() {
  return (
    <main className="container mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-8">Dashboard</h1>

      {/* Each section loads independently */}
      <Suspense fallback={<StatsCardsSkeleton />}>
        <StatsCards />
      </Suspense>

      <Suspense fallback={<ChartSkeleton />}>
        <DashboardChart />
      </Suspense>

      <Suspense fallback={<ActivitySkeleton />}>
        <RecentActivity />
      </Suspense>
    </main>
  );
}

// Each child component does its own fetch
// components/dashboard/StatsCards.tsx (Server Component)
async function StatsCards() {
  const stats = await db.analytics.getStats(); // May take 200ms

  return (
    <div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
      {stats.map((stat) => (
        <div key={stat.label} className="rounded-xl border p-4">
          <p className="text-sm text-gray-500">{stat.label}</p>
          <p className="text-3xl font-bold">{stat.value}</p>
        </div>
      ))}
    </div>
  );
}

Error Boundaries with error.tsx

Next.js provides a built-in error handling mechanism at the route level through the error.tsx file. It acts as a React Error Boundary that catches errors in both Server and Client Components within its route segment.

typescript
// app/[locale]/dashboard/error.tsx
"use client"; // Error boundaries MUST be Client Components

import { useEffect } from "react";

export default function DashboardError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    // Log the error to a monitoring service
    console.error("Dashboard error:", error);
  }, [error]);

  return (
    <div className="flex flex-col items-center justify-center py-20">
      <div className="rounded-xl border border-red-200 bg-red-50 p-8 text-center max-w-md">
        <h2 className="text-xl font-semibold text-red-800">
          Something went wrong
        </h2>
        <p className="mt-2 text-red-600">
          Failed to load dashboard data.
        </p>
        {error.digest && (
          <p className="mt-1 text-sm text-red-400">
            Error code: {error.digest}
          </p>
        )}
        <button
          onClick={reset}
          className="mt-4 rounded-lg bg-red-600 px-4 py-2 text-white hover:bg-red-700 transition-colors"
        >
          Try again
        </button>
      </div>
    </div>
  );
}

Key points about error.tsx:

  • Always a Client Component: It must have "use client" because Error Boundaries are a client-side feature in React.
  • Receives reset: A function that lets the user retry rendering the route segment.
  • digest property: A hash of the error automatically generated by Next.js that can be used to track the error in server logs without exposing sensitive details.
  • Segment scope: Each folder can have its own error.tsx, enabling granular error UI per section.

Not Found Handling

typescript
// app/[locale]/blog/[slug]/page.tsx
import { notFound } from "next/navigation";

export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await db.post.findUnique({ where: { slug } });

  if (!post) notFound(); // Renders the nearest not-found.tsx

  return <article>{/* ... */}</article>;
}

// app/[locale]/blog/not-found.tsx
export default function BlogNotFound() {
  return (
    <div className="flex flex-col items-center justify-center py-20">
      <h2 className="text-2xl font-bold">Article Not Found</h2>
      <p className="mt-2 text-gray-600">
        The article you are looking for does not exist or has been removed.
      </p>
    </div>
  );
}

Dynamic Imports with next/dynamic

next/dynamic allows you to load components lazily, splitting them into separate chunks that are only downloaded when needed. This is especially useful for heavy components or those that depend on browser APIs.

typescript
// app/[locale]/page.tsx
import dynamic from "next/dynamic";

// The component is only loaded when it renders
const HeavyEditor = dynamic(
  () => import("@/components/editor/RichTextEditor"),
  {
    loading: () => (
      <div className="h-64 animate-pulse rounded-xl bg-gray-200" />
    ),
    ssr: false, // Do not render on the server (uses browser APIs)
  }
);

// Component that only works in the browser
const MapComponent = dynamic(
  () => import("@/components/maps/InteractiveMap"),
  {
    loading: () => (
      <div className="h-96 rounded-xl bg-gray-100 flex items-center justify-center">
        <p className="text-gray-500">Loading map...</p>
      </div>
    ),
    ssr: false,
  }
);

export default function HomePage() {
  return (
    <main>
      <section className="py-12">
        <h2 className="text-2xl font-bold mb-4">Editor</h2>
        <HeavyEditor />
      </section>

      <section className="py-12">
        <h2 className="text-2xl font-bold mb-4">Location</h2>
        <MapComponent />
      </section>
    </main>
  );
}

When to use dynamic() vs when to use Suspense:

  • dynamic() with ssr: false: For components that depend on browser APIs (window, document, canvas). Prevents hydration errors.
  • dynamic() with ssr: true (default): For code-splitting heavy components. They render on the server but the JS is loaded as a separate chunk.
  • Suspense: For streaming async Server Components. Lets you show a fallback while the component resolves on the server.

Common Mistakes and How to Avoid Them

Mistake 1: Using Hooks in Server Components

typescript
// WRONG: useState does not work in Server Components
// app/[locale]/page.tsx
import { useState } from "react"; // Runtime error

export default function Page() {
  const [count, setCount] = useState(0); // Error: hooks not available
  return <p>{count}</p>;
}

// CORRECT: Extract the interactive part into a Client Component
// app/[locale]/page.tsx (Server Component)
import { Counter } from "@/components/Counter";

export default function Page() {
  return (
    <main>
      <h1>My Page</h1>
      <Counter /> {/* Client Component with useState */}
    </main>
  );
}

Mistake 2: Importing Server Code in Client Components

typescript
// WRONG: Importing server logic in a Client Component
"use client";

import { db } from "@/lib/database"; // ERROR: db uses Node.js APIs

export function UserList() {
  // This will never work on the client
  const users = db.user.findMany(); // Runtime error
  return <ul>{/* ... */}</ul>;
}

// CORRECT: Fetch from an API route or pass data via props
"use client";

import { useState, useEffect } from "react";

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

  useEffect(() => {
    fetch("/api/users")
      .then((res) => res.json())
      .then((data) => setUsers(data));
  }, []);

  return <ul>{/* ... */}</ul>;
}

Mistake 3: Placing "use client" Too High

typescript
// WRONG: Marking the entire layout as a Client Component
// app/[locale]/layout.tsx
"use client"; // Now NOTHING in this branch can be a Server Component

export default function Layout({ children }) {
  return <div>{children}</div>;
}

// CORRECT: Keep the layout as a Server Component
// Extract only the interactive parts into Client Components
// app/[locale]/layout.tsx (Server Component)
import { Navbar } from "@/components/common/Navbar"; // Client Component
import { Footer } from "@/components/common/Footer"; // Server Component

export default function Layout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div>
      <Navbar /> {/* Only this is a Client Component */}
      {children}  {/* Can contain Server Components */}
      <Footer /> {/* Server Component: no interactivity */}
    </div>
  );
}

Mistake 4: Passing Non-Serializable Data to Client Components

typescript
// WRONG: Passing functions or Dates to Client Components
export default async function Page() {
  const data = await getData();

  return (
    <ClientComponent
      date={new Date()}        // Error: Date is not serializable
      onClick={() => {}}       // Error: functions are not serializable
      map={new Map()}          // Error: Map is not serializable
    />
  );
}

// CORRECT: Serialize everything before passing it
export default async function Page() {
  const data = await getData();

  return (
    <ClientComponent
      date={new Date().toISOString()} // ISO string
      items={Array.from(someMap)}     // Array of tuples
    />
  );
}

Final tip: React Server Components are not a replacement for Client Components but a complement to them. The key is finding the right balance: use Server Components for fetching, data transformation, and static rendering; use Client Components exclusively for interactivity. Keep the "use client" directive as low as possible in the component tree to maximize the benefits of RSC. This architecture produces faster, lighter, and more maintainable applications.

Share:

Related articles