Problem Context

For a decade, every React component was a Client Component โ€” shipped to the browser as JavaScript, hydrated, and run again in the user's runtime. That bought interactivity but cost bundle size, time-to-interactive, and a forced trip-through-an-API for any data the component needed.

React Server Components (RSC), now stable in React 19 and the default in Next.js 16's App Router, flip the model: components run on the server, fetch data directly from your database, and render to a compact wire format that the client merges into its tree. Zero JavaScript shipped for purely server components, and no client-side data-fetching waterfall.

๐Ÿค” Sound familiar?
  • You add 'use client' to every file out of habit and ship 200KB of JS for a static page
  • You wrote a useEffect + useState dance to fetch data the server already had
  • You confused Server Components with SSR (Server-Side Rendering) and can't explain the difference
  • You tried to import a Server Component into a Client Component and got a cryptic error

This guide is the boundary mental model โ€” what runs where, what can cross, and how Next.js 16 wires it together.

Concept Explanation

Two component types, two runtimes:

  • Server Component โ€” runs only on the server during a request. Can be async, can await a database query, can read environment secrets. Renders to the RSC Payload (a compact serialized tree) plus HTML for the initial response. Ships zero JS.
  • Client Component โ€” opted in with 'use client'. Renders on the server (for HTML) AND ships its JS to the browser for hydration and subsequent renders. Anything interactive (state, effects, browser APIs, event handlers) must live here.

flowchart TB
    A["Request hits Next.js"] --> B["Run Server Components<br/>(async, hits DB directly)"]
    B --> C["Generate RSC Payload<br/>+ initial HTML"]
    C --> D["Stream to browser"]
    D --> E["Browser shows HTML instantly"]
    E --> F["Hydrate Client Components<br/>(only the islands marked 'use client')"]
    F --> G["RSC Payload reconciles<br/>Client + Server trees"]

    style B fill:#059669,color:#fff,stroke:#047857
    style F fill:#0891b2,color:#fff,stroke:#0e7490
    style C fill:#4f46e5,color:#fff,stroke:#4338ca

The directive boundary

'use client' at the top of a file marks a boundary, not just that file. Every module imported (transitively) from a client file becomes part of the client bundle. So you put 'use client' at the lowest leaf possible โ€” usually the small interactive widget, never the page.

'use server' at the top of a file or function marks a Server Action โ€” a function the client can invoke that runs on the server. It is not what makes a component a Server Component (Server Components are the default).

Implementation

Step 1: A typical Next.js 16 page โ€” async Server Component

// app/posts/[slug]/page.tsx โ€” a Server Component (default)
import { db } from '@/lib/db';
import { LikeButton } from './like-button';

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>; // Next 16: params is a Promise
}) {
  const { slug } = await params;
  const post = await db.post.findUnique({ where: { slug } });
  if (!post) return <p>Not found</p>;

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.html }} />
      {/* Hand off the count to a Client Component for interactivity */}
      <LikeButton postId={post.id} initialLikes={post.likes} />
    </article>
  );
}

Step 2: Client Component โ€” small, leaf-level

// app/posts/[slug]/like-button.tsx
'use client';
import { useOptimistic, useTransition } from 'react';
import { likePost } from './actions';

export function LikeButton({
  postId,
  initialLikes,
}: { postId: string; initialLikes: number }) {
  const [optimistic, addOptimistic] = useOptimistic(
    initialLikes,
    (current: number, delta: number) => current + delta,
  );
  const [pending, startTransition] = useTransition();

  return (
    <button
      disabled={pending}
      onClick={() =>
        startTransition(async () => {
          addOptimistic(1);
          await likePost(postId); // Server Action
        })
      }
    >
      โ™ฅ {optimistic}
    </button>
  );
}

Step 3: Server Action โ€” the mutation path

// app/posts/[slug]/actions.ts
'use server';
import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';

export async function likePost(postId: string) {
  await db.post.update({
    where: { id: postId },
    data: { likes: { increment: 1 } },
  });
  revalidatePath(`/posts/${postId}`); // bust the route cache
}

Step 4: Composition โ€” passing Server Components as children

A Client Component cannot import a Server Component (the import would pull server-only code into the client bundle). But it can render one passed via children or any prop slot.

// Client wrapper that adds a collapsible UI
'use client';
import { useState } from 'react';

export function Collapsible({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setOpen(o => !o)}>{open ? 'Hide' : 'Show'}</button>
      {open && children}
    </div>
  );
}

// Server Component composes them โ€” Comments runs on the server
export default async function Page() {
  return (
    <Collapsible>
      <Comments /> {/* Server Component, async, hits DB */}
    </Collapsible>
  );
}

Step 5: Stream slow data with <Suspense>

import { Suspense } from 'react';

export default function Page() {
  return (
    <>
      <h1>Dashboard</h1>
      <FastWidget />
      <Suspense fallback={<Skeleton />}>
        <SlowWidget /> {/* awaits a slow API; HTML streams when ready */}
      </Suspense>
    </>
  );
}

async function SlowWidget() {
  const data = await fetch('https://slow.example.com/data').then(r => r.json());
  return <pre>{JSON.stringify(data, null, 2)}</pre>;
}

Pitfalls

1. Adding 'use client' at the page level. Now everything below it is a Client Component. You lose direct DB access, your bundle balloons, and you re-introduce the loading-spinner waterfall. Push the boundary down to the leaf widget.

2. Importing a Server Component into a Client Component.Doesn't work โ€” the import graph would leak server code. Pass the Server Component as a child or prop instead.

3. Forgetting params is a Promise (Next 16). const { slug } = params compiles but is undefined. Always await params. Same for searchParams.

4. Using useState/useEffect in a Server Component. The error is loud: hooks aren't available there. If you reach for state, that subtree should be a Client Component.

5. Treating Server Actions as RPC for everything. Server Actions are perfect for mutations triggered by user UI. For pure data reads from a Client Component, prefer fetching in the parent Server Component and passing data as a prop, or use a Route Handler with React Query.

6. Confusing RSC with SSR. SSR renders Client Components to HTML on the server once, then ships the JS to re-render them in the browser. RSC components only ever run on the server and ship zero JS for themselves. Your app uses both at once.

Practical Takeaways

  • Server Components are the default in Next.js 16 App Router. Add 'use client' only where you need state, effects, or browser APIs.
  • Push the client boundary as far down the tree as possible โ€” usually a small leaf widget.
  • Client Components can't import Server Components, but can compose them via children.
  • Use Server Actions ('use server') for mutations; pair with useOptimistic + useTransition for snappy UX.
  • Wrap slow data in <Suspense> โ€” Next.js streams HTML as Server Components resolve.
  • Read environment secrets (DB URLs, API keys) only in Server Components or Server Actions โ€” never in Client Components.
  • Remember Next 16: params, searchParams, cookies(), and headers() are now async.