React Server Components: the mental model that makes the pattern click

By Yuki Tanaka · 19 July 2026139 views
React Server Components: the mental model that makes the pattern click

A senior React developer joins a Next.js 14 project after years on a Create React App codebase. On her first day, she moves data fetching from a Server Component to a Client Component because "that's how you fetch data." The fetch works. But the page loses its streaming capability, increases its bundle size by importing the data fetching library client-side, and adds a client-server network round-trip that was not there before.

She was not wrong about how to fetch data in React. She was wrong about which execution environment was appropriate for that component. Her mental model was "all React components run on the client." In Next.js App Router, that assumption is false.

The two execution environments

React has always run on the server for initial page rendering (Server-Side Rendering) and on the client for interactivity. What makes Server Components a different model — not just SSR — is that Server Components only ever run on the server. They are never hydrated. They never become interactive in the browser.

This means:

  • Server Components can access server-only resources directly. Databases, file systems, internal APIs, environment variables with secrets. No API layer required.
  • Server Components do not add to the JavaScript bundle. Code that only runs on the server is never sent to the browser. A Server Component that imports a 200KB PDF parsing library adds 0KB to the client bundle.
  • Server Components cannot use browser APIs or event handlers. No useState, no useEffect, no onClick. Components that need these must be Client Components.

The mental model shift: think of Server Components as backend code that happens to return UI. Think of Client Components as the interactive layer that runs in the browser — as it always has.

The component tree in practice

In a Next.js App Router application, Server Components are the default. Client Components are opt-in via the 'use client' directive. The two can be composed, but with a specific constraint: a Server Component can render a Client Component, but a Client Component cannot render a Server Component (unless passed as children/props).

// app/products/page.tsx — Server Component (no 'use client')
// Runs on the server. Directly accesses the database.
import { db } from '@/lib/database';
import { ProductGrid } from '@/components/ProductGrid';
import { AddToCartButton } from '@/components/AddToCartButton';

export default async function ProductsPage({
  searchParams
}: {
  searchParams: { category?: string }
}) {
  // Direct database access — no API call, no fetch()
  // This code never runs in the browser
  const products = await db.product.findMany({
    where: searchParams.category
      ? { categoryId: searchParams.category }
      : undefined,
    include: { images: { take: 1 } },
    take: 24
  });

  return (
    <main>
      <h1>Products</h1>
      {/* ProductGrid is also a Server Component — no interactivity needed */}
      <ProductGrid products={products} />
    </main>
  );
}

// components/ProductGrid.tsx — Server Component
// Renders the grid layout. No interactivity.
import { Product } from '@prisma/client';
import { AddToCartButton } from './AddToCartButton';

export function ProductGrid({ products }: { products: Product[] }) {
  return (
    <div className="grid grid-cols-3 gap-4">
      {products.map(product => (
        <div key={product.id} className="product-card">
          <img src={product.images[0]?.url} alt={product.name} />
          <h3>{product.name}</h3>
          <p>${product.price}</p>
          {/* Client Component imported into a Server Component — this is fine */}
          <AddToCartButton productId={product.id} />
        </div>
      ))}
    </div>
  );
}

// components/AddToCartButton.tsx — Client Component ('use client')
// Only the interactive parts are Client Components
'use client';
import { useState } from 'react';
import { addToCart } from '@/lib/cart-actions';

export function AddToCartButton({ productId }: { productId: string }) {
  const [isAdding, setIsAdding] = useState(false);

  const handleClick = async () => {
    setIsAdding(true);
    await addToCart(productId);
    setIsAdding(false);
  };

  return (
    <button onClick={handleClick} disabled={isAdding}>
      {isAdding ? 'Adding...' : 'Add to cart'}
    </button>
  );
}

The key observation: ProductsPage and ProductGrid are Server Components that access the database directly. AddToCartButton is a Client Component that handles user interaction. The database import is never sent to the browser. The useState import is never executed on the server.

The 'use client' boundary

'use client' marks a component and all its imports as Client Components. This has an important implication: a file that is imported by a Client Component is also treated as a Client Component, even without its own 'use client' directive.

// The boundary propagates downward through imports:

// page.tsx (Server Component)
//   └── ProductForm.tsx ('use client' — client boundary starts here)
//         └── useFormState.ts (treated as client code — imported by a Client Component)
//         └── ValidationSchema.ts (treated as client code — imported by a Client Component)
//         └── ProductImageUploader.tsx (treated as client code — no 'use client' needed)

// The entire ProductForm subtree is client-side code.
// None of it can directly access the database.

The implication for data fetching: if a Client Component needs data from the database, it must either:

  1. Receive the data as props from a Server Component parent
  2. Fetch the data from an API route (/api/...)
  3. Use a Server Action

The third option is particularly powerful:

// Server Action — runs on the server, callable from Client Components
// lib/actions.ts
'use server';

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

export async function addToCart(productId: string): Promise<void> {
  const session = await getSession();
  await db.cartItem.upsert({
    where: { userId_productId: { userId: session.userId, productId } },
    create: { userId: session.userId, productId, quantity: 1 },
    update: { quantity: { increment: 1 } }
  });
  revalidatePath('/cart');
}

addToCart is a Server Action — marked with 'use server'. It runs on the server when called from the Client Component AddToCartButton. The database is never exposed to the client. The Session is validated on the server.

The boundary placement decision

The question for each component: does this component need to be interactive, or does it just render data?

Server Component: displays data, renders static structure, accesses server resources, imports large or server-only libraries.

Client Component: handles user events (onClick, onChange), uses browser APIs, uses React state or effects, renders only when user interacts.

The mistake most developers make when adopting the App Router is to make everything a Client Component "to be safe." This works but misses the performance and security benefits that Server Components provide. The products page that fetches 24 products directly from the database in a Server Component sends the data in the initial HTML response — no separate API call, no client-side fetch, no loading state.

Patterns that commonly cause confusion

Passing non-serializable values across the boundary. Server Components can pass props to Client Components, but the props must be serializable (JSON-compatible). Passing a Prisma query result object (which has methods) directly to a Client Component fails at runtime:

// WRONG: product is a Prisma object with methods — not serializable
<ClientComponent product={prismaProduct} />

// CORRECT: serialize to a plain object first
<ClientComponent product={{
  id: prismaProduct.id,
  name: prismaProduct.name,
  price: prismaProduct.price
}} />

Async Context in Client Components. async/await is available in Server Components but not directly in Client Components (React 19 introduces use() for this, but it is different from server-side async). Client Components that need to fetch data should use useEffect or React Query/SWR.

Shared state between Server and Client Components. State (useState) lives in the Client Component tree. Server Components cannot access or modify client state directly. The data flow is one-way: Server Components pass data down to Client Components as props or through the RSC protocol.

The mental model that unlocks Server Components: imagine your React tree as having two layers. The server layer executes once at request time and produces static HTML and React state initialization. The client layer hydrates that HTML and makes it interactive. Server Components live in the server layer; Client Components live in both layers (run on server for initial HTML, then hydrate and run on client). Code that belongs on the server — database access, secret handling, heavy computation — should be in Server Components. Code that belongs on the client — event handling, browser APIs, animations — should be in Client Components with 'use client'.

Common mistakes when adopting React Server Components

Making everything a Client Component by default. The instinct to add 'use client' everywhere feels safe — Client Components work the way developers expect React to work. But the boundary placement matters. Every Client Component added to the top of the tree pulls its entire import graph client-side. A dashboard with ten widgets where only two are interactive, but the top-level layout has 'use client', sends all ten widgets to the browser as JavaScript. Push the 'use client' boundary down to the smallest interactive leaf:

// WRONG: Client boundary at the layout level
// app/dashboard/layout.tsx
'use client';  // Now everything in this subtree is client-side code

import { DashboardNav } from './DashboardNav';
import { StatCards } from './StatCards';       // Pure display — doesn't need client
import { RevenueChart } from './RevenueChart'; // Interactive — needs client

export default function DashboardLayout({ children }) {
  return (
    <>
      <DashboardNav />
      <StatCards />       {/* Sent to client unnecessarily */}
      <RevenueChart />
      {children}
    </>
  );
}

// CORRECT: Client boundary at the component level
// app/dashboard/layout.tsx — Server Component (no 'use client')
import { DashboardNav } from './DashboardNav'; // Server Component
import { StatCards } from './StatCards';       // Server Component — direct DB access
import { RevenueChart } from './RevenueChart'; // Client Component ('use client' inside)

export default function DashboardLayout({ children }) {
  return (
    <>
      <DashboardNav />
      <StatCards />       {/* Runs on server — DB access, zero client JS */}
      <RevenueChart />    {/* Client boundary inside RevenueChart only */}
      {children}
    </>
  );
}

Trying to import a Server Component into a Client Component. The module graph rule is that a Client Component's imports become Client Components. A Server Component imported into a Client Component breaks the model — it would need to run on the server but is in a module graph that runs on the client. React throws an error. The solution is to pass Server Components as children or as props:

// WRONG: Importing a Server Component into a Client Component
'use client';
import { ServerSideWidget } from './ServerSideWidget'; // Error: cannot import SC into CC

export function ClientLayout() {
  return <ServerSideWidget />; // This does not work
}

// CORRECT: Pass the Server Component as children from a Server Component parent
// app/page.tsx — Server Component
import { ClientLayout } from './ClientLayout';
import { ServerSideWidget } from './ServerSideWidget';

export default function Page() {
  return (
    <ClientLayout>
      {/* ServerSideWidget is passed as children — renders on the server,
          result is sent as serialized React nodes to the client */}
      <ServerSideWidget />
    </ClientLayout>
  );
}

// components/ClientLayout.tsx
'use client';
import { useState } from 'react';

export function ClientLayout({ children }: { children: React.ReactNode }) {
  const [isCollapsed, setIsCollapsed] = useState(false);
  return (
    <div className={isCollapsed ? 'layout--collapsed' : 'layout'}>
      <button onClick={() => setIsCollapsed(c => !c)}>Toggle</button>
      {children} {/* ServerSideWidget renders here — it was already rendered server-side */}
    </div>
  );
}

Using Server Actions for non-mutation operations. Server Actions are designed for data mutations — form submissions, writes, updates. They run on the server and trigger revalidation. Using them as a general-purpose data fetching mechanism introduces unnecessary overhead. For reads, Server Components with direct database access are the correct approach. Server Actions are the answer when a Client Component needs to trigger a write without exposing the database to the client.

Not understanding how revalidatePath and revalidateTag interact with the cache. After a Server Action mutates data, the page must be revalidated to show the updated data. revalidatePath('/products') invalidates the cache for the products page. Without it, the user sees stale data after a mutation:

// lib/actions.ts
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
import { db } from '@/lib/database';

export async function updateProductPrice(
  productId: string,
  newPrice: number
): Promise<void> {
  await db.product.update({
    where: { id: productId },
    data: { price: newPrice }
  });

  // Without these, the user sees the old price until a hard refresh
  revalidatePath('/products');           // Revalidate the products list page
  revalidatePath(`/products/${productId}`); // Revalidate the specific product page
  revalidateTag('product-list');         // Revalidate anything tagged 'product-list'
}

Accessing searchParams in a Client Component for server-driven filtering. When a filter is applied (category, sort order), the pattern that works correctly in the App Router is to pass the filter as a URL search parameter and read it in the Server Component — not to manage it as client state. Client state does not trigger a Server Component re-render:

// CORRECT: URL-driven filtering — Server Component reads searchParams
// app/products/page.tsx — Server Component
export default async function ProductsPage({
  searchParams
}: {
  searchParams: { category?: string; sort?: string }
}) {
  const products = await db.product.findMany({
    where: searchParams.category
      ? { category: searchParams.category }
      : undefined,
    orderBy: searchParams.sort === 'price'
      ? { price: 'asc' }
      : { createdAt: 'desc' }
  });

  return <ProductGrid products={products} />;
}

// components/FilterBar.tsx — Client Component
'use client';
import { useRouter, useSearchParams } from 'next/navigation';

export function FilterBar() {
  const router = useRouter();
  const searchParams = useSearchParams();

  const setFilter = (category: string) => {
    const params = new URLSearchParams(searchParams.toString());
    params.set('category', category);
    // Navigating updates the URL, which triggers a Server Component re-render
    router.push(`/products?${params.toString()}`);
  };

  return (
    <div>
      <button onClick={() => setFilter('electronics')}>Electronics</button>
      <button onClick={() => setFilter('clothing')}>Clothing</button>
    </div>
  );
}

What the Server/Client boundary enables long-term

The teams that benefit most from React Server Components are those building data-heavy applications where the database access pattern matters. A product catalog with 10,000 items, where the initial page shows 24 items fetched directly from the database in a Server Component, sends 24 items worth of data to the client — not the JavaScript bundle needed to fetch and render them. A dashboard where five of eight panels are pure data display and three are interactive sends only the three interactive panels as client JavaScript.

The boundary also clarifies security responsibilities. Data access logic that lives in Server Components or Server Actions never reaches the client — it cannot be called, inspected, or bypassed through browser developer tools. API routes become optional for mutations that Server Actions cover, and for reads that Server Components cover directly.

The developer who made everything a Client Component on day one was not wrong to be cautious. The correct approach is to start with Server Components as the default and add 'use client' only where interactivity requires it. That is the direction the framework is designed for, and the direction that produces smaller bundles, faster initial loads, and cleaner separation between data access and UI rendering.

Comments

No comments yet. Be the first!

Sign in to leave a comment.

React Server Components: the mental model that makes the pattern click — ANN Tech