Skip to main content

React Server Components: The Mental Model Shift You Need

March 17, 2026

</>

React Server Components (RSCs) are not a new API you learn and move on. They're a fundamental shift in how you think about building React applications. The code looks similar — it's still JSX, still components, still props — but the execution model is completely different. Until you internalize that difference, you'll fight the framework instead of leveraging it.

What Server Components Actually Are

A Server Component runs on the server at request time (or build time for static pages). It renders to HTML and a special serialized format that React uses to hydrate the client. The component's code — its imports, dependencies, and logic — never reaches the browser.

This means a Server Component can:

  • Directly access databases, file systems, and internal APIs
  • Use server-only packages (Node.js modules, ORMs, SDKs)
  • Keep secrets and API keys completely off the client
  • Reduce the JavaScript bundle sent to the browser

It cannot:

  • Use useState, useEffect, or any React hook that depends on the browser
  • Add event handlers (onClick, onChange, etc.)
  • Access browser APIs (window, document, localStorage)
  • Re-render based on user interaction

In the Next.js App Router, every component is a Server Component by default. You opt into client rendering with the 'use client' directive.

The Client/Server Boundary

This is the concept that trips people up most. The boundary is the line between server-rendered and client-rendered components. It's declared with 'use client' at the top of a file.

Server Component (default)
  └── Server Component
        └── 'use client'  THE BOUNDARY
              └── Client Component
                    └── Client Component (everything below is client)

Key rules:

  1. Server Components can import Client Components — this is how you compose them
  2. Client Components cannot import Server Components — the import graph flows one way
  3. Everything below a 'use client' boundary is client — child components don't need their own directive
  4. Props crossing the boundary must be serializable — no functions, no classes, no Dates (use strings)
// app/dashboard/page.tsx — Server Component (default)
import { db } from "@/lib/database";
import { DashboardChart } from "./chart"; // Client Component

export default async function DashboardPage() {
  // This runs on the server — direct database access
  const stats = await db.query("SELECT * FROM daily_stats");

  // Pass serializable data to the client component
  return (
    <div>
      <h1>Dashboard</h1>
      <DashboardChart data={stats} />
    </div>
  );
}
// app/dashboard/chart.tsx — Client Component
"use client";

import { useState } from "react";
import { BarChart } from "recharts";

export function DashboardChart({ data }) {
  const [range, setRange] = useState("7d");

  // Filter data based on user selection
  const filtered = data.filter(/* ... */);

  return (
    <div>
      <select value={range} onChange={(e)=> setRange(e.target.value)}>
        <option value="7d">Last 7 days</option>
        <option value="30d">Last 30 days</option>
      </select>
      <BarChart data={filtered} />
    </div>
  );
}

The server fetches data. The client handles interactivity. Each does what it's best at.

When to Use 'use client'

Add 'use client' when your component needs any of these:

  • StateuseState, useReducer
  • EffectsuseEffect, useLayoutEffect
  • Event handlersonClick, onSubmit, onChange
  • Browser APIswindow, document, localStorage, IntersectionObserver
  • Custom hooks that use any of the above
  • Third-party libraries that use any of the above (chart libraries, animation libraries, etc.)

Do NOT add 'use client' just because:

  • You're importing a utility function (pure functions work on both)
  • You're using async/await (Server Components support async natively)
  • You're passing props (all components use props)
  • A child component is a Client Component (the parent can still be a Server Component)

Data Fetching Patterns

Pattern 1: Fetch in Server Components

The simplest and best pattern. Server Components can be async, so just fetch your data directly:

// app/blog/page.tsx
async function BlogPage() {
  const posts = await fetch("https://api.example.com/posts", {
    next: { revalidate: 3600 }, // Cache for 1 hour
  }).then((r) => r.json());

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

No useEffect. No loading state management. No useState for data. The component fetches data, renders HTML, sends it to the browser. Done.

Pattern 2: Parallel Data Fetching

When you need multiple data sources, fetch them in parallel:

async function DashboardPage() {
  // These run in parallel, not sequentially
  const [user, stats, notifications] = await Promise.all([
    getUser(),
    getStats(),
    getNotifications(),
  ]);

  return (
    <>
      <UserHeader user={user} />
      <StatsGrid stats={stats} />
      <NotificationList notifications={notifications} />
    </>
  );
}

Pattern 3: Streaming with Suspense

For slow data sources, wrap them in Suspense to stream the page progressively:

import { Suspense } from "react";

async function DashboardPage() {
  const user = await getUser(); // Fast — wait for this

  return (
    <>
      <UserHeader user={user} />
      <Suspense fallback={<StatsPlaceholder />}>
        <SlowStatsSection /> {/* Slow — stream when ready */}
      </Suspense>
    </>
  );
}

async function SlowStatsSection() {
  const stats= await getStatsFromSlowAPI(); // Takes 3 seconds
  return <StatsGrid stats={stats} />;
}

The page loads instantly with the user header and a placeholder. The stats stream in when ready. No client-side loading states, no layout shift, no spinner management.

Pattern 4: Server Actions for Mutations

Server Actions let Client Components call server-side functions:

// app/actions.ts
"use server";

import { db } from "@/lib/database";
import { revalidatePath } from "next/cache";

export async function createPost(formData: FormData) {
  const title = formData.get("title") as string;
  const content = formData.get("content") as string;

  await db.insert("posts", { title, content });
  revalidatePath("/blog");
}
// app/blog/new/form.tsx
"use client";

import { createPost } from "../actions";

export function NewPostForm() {
  return (
    <form action={createPost}>
      <input name="title" placeholder="Title" />
      <textarea name="content" placeholder="Write your post..." />
      <button type="submit">Publish</button>
    </form>
  );
}

The form submits to a server function. No API route needed. The revalidatePath call tells Next.js to refresh the blog page with updated data.

Common Mistakes

Mistake 1: Making Everything a Client Component

If your first instinct is to add 'use client' to fix an error, stop. Ask: does this component actually need interactivity? Often you can restructure to keep the parent as a Server Component and push 'use client' down to a small interactive child.

Mistake 2: Fetching Data in Client Components

// Don't do this
"use client";
export function UserList() {
  const [users, setUsers] = useState([]);
  useEffect(() => {
    fetch("/api/users").then(/* ... */);
  }, []);
  // ...
}
// Do this instead
// Server Component — no directive needed
export default async function UserList() {
  const users = await getUsers();
  return <UserListClient users={users} />;
}

Fetch on the server, pass data as props. The client component gets pre-rendered HTML with data already in it.

Mistake 3: Passing Non-Serializable Props

// This will error
<ClientComponent
  onSave={async ()=> { await db.save() }}  // Functions can't cross the boundary
  date={new Date()}                          // Date objects can't cross either
/>

// Do this instead
<ClientComponent
  saveAction={saveServerAction}  // Server Actions are serializable
  date={date.toISOString()}      // Strings are serializable
/>

Mistake 4: Importing Server-Only Code in Client Components

If a Client Component imports a module that uses Node.js APIs, the build will fail. Use the server-only package to get clear errors:

npm install server-only
// lib/database.ts
import "server-only";
import { Pool } from "pg";

export const db = new Pool({ connectionString: process.env.DATABASE_URL });

Now if any Client Component tries to import database.ts, you get a build-time error instead of a confusing runtime failure.

Practical Migration Tips

If you're moving an existing Next.js Pages Router app to the App Router with Server Components:

  1. Start with leaf components. Move pages one at a time, starting with the simplest ones — static pages, marketing pages, blog posts.

  2. Identify the interactive parts. In each page, find the components that use state, effects, or event handlers. Those become Client Components. Everything else stays as Server Components.

  3. Push 'use client' down. Don't put it at the page level. Put it on the smallest component that needs it. A page might have one interactive dropdown — only that dropdown needs to be a Client Component.

  4. Move data fetching to the server. Replace useEffect + fetch + useState patterns with direct data fetching in Server Components. Delete your loading state management.

  5. Replace API routes with Server Actions for mutations that are only used by your own UI. Keep API routes for external consumers.

The mental model is simple once it clicks: Server Components are for reading data and rendering HTML. Client Components are for interactivity. Keep the boundary as low in your component tree as possible, and push data fetching up to the server. The framework handles everything in between.


For more frontend fundamentals, check out Modern JavaScript: Writing Code That Doesn't Hurt and Building Production-Ready Apps with Next.js.

Recommended Posts