BlogNext.js

React Server Components: what they actually are (and why most explanations get it wrong)

Aba Nicaisse
Aba Nicaisse
Apr 18, 2026
React Server Components: what they actually are (and why most explanations get it wrong)

Most RSC articles teach you a mental model that was accurate for Next.js 14. Ship that code in 2025 against Next.js 15 or 16, and you'll hit runtime errors on day one. The framework moved; the tutorials didn't. Let's fix that.

1. The mental model you actually need

Forget "server vs. client" as a location distinction. Think of it as a bundle membership distinction.

React now operates with two component trees that get reconciled together:

  • The server tree executes once, at request time. Its output is serialized into a wire format, not HTML, and streamed to the client.
  • The client tree ships JavaScript to the browser and behaves like React always has.

Server Components live only in the server tree. They never become JavaScript in your bundle. They can async/await directly, read databases, access secrets and none of that code ever touches the client.

That part hasn't changed. What has changed is how you access route data inside those components.

2. Params are now Promises and this breaks most tutorials

Starting in Next.js 15, and enforced fully in Next.js 16, params and searchParams are asynchronous Promises. Synchronous destructuring, the pattern you'll find in 90% of existing tutorials, now throws warnings in 15 and hard errors in 16.

Here's what the old pattern looks like, and why you can't write it anymore:

typescript
// ❌ Next.js 16 — synchronous params access throws a runtime error export default async function PostPage({ params }: { params: { slug: string } }) { const post = await db.query.posts.findFirst({ where: (posts, { eq }) => eq(posts.slug, params.slug), // params.slug errors here }); // ... }

The correct pattern awaits params before touching any of its properties:

typescript
// app/posts/[slug]/page.tsx // ✅ Next.js 16 — params resolved as a Promise first import { db } from "@/lib/db"; import { notFound } from "next/navigation"; interface PageProps { params: Promise<{ slug: string }>; searchParams: Promise<{ page?: string }>; } export default async function PostPage({ params, searchParams }: PageProps) { const { slug } = await params; const { page = "1" } = await searchParams; const post = await db.query.posts.findFirst({ where: (posts, { eq }) => eq(posts.slug, slug), with: { author: true, tags: true }, }); if (!post) notFound(); return ( <article> <h1>{post.title}</h1> <p>By {post.author.name} · Page {page}</p> <PostContent content={post.content} /> </article> ); }

Two things are worth noting here. First, both params and searchParams are typed as Promise<...> not plain objects. Second, you can await them independently, which means Next.js can resolve them in parallel if your runtime supports it. Don't chain them unnecessarily.

The same rule applies to Metadata APIs. If you generate dynamic metadata using generateMetadata, params must be awaited there too:

typescript
// ✅ Next.js 16 — async params in generateMetadata export async function generateMetadata({ params }: PageProps) { const { slug } = await params; const post = await db.query.posts.findFirst({ where: (posts, { eq }) => eq(posts.slug, slug), columns: { title: true, excerpt: true }, }); if (!post) return {}; return { title: post.title, description: post.excerpt, }; }

3. The wire format still isn't HTML and that still matters

RSCs do not produce HTML. They produce a React-specific serialization payload that looks roughly like this:

typescript
["$","article",null,{"children":[ ["$","h1",null,{"children":"Understanding RSCs"}], ["$","$L1",null,{"content":"..."}] ]}]

That $L1 reference is a Client Component boundary. The server tree signals "there's a client component here" and the client picks it up, hydrates it, and wires in interactivity. This is what makes RSCs composable, they share a single React tree with a clean boundary protocol between them, not two separate rendering systems bolted together.

4. Client Component boundaries: what actually crosses the wire

You introduce a Client Component with "use client" at the top of a file. Everything that component imports becomes client-side JavaScript. The critical mental model:

"use client" doesn't mean "only runs on the client." It means "this is the root of a client bundle subtree."

Here's a correctly structured interactive component that receives serialized props from a Server Component:

typescript
// components/like-button.tsx "use client"; import { useState } from "react"; interface Props { initialCount: number; postId: string; } export function LikeButton({ initialCount, postId }: Props) { const [count, setCount] = useState(initialCount); const [liked, setLiked] = useState(false); const handleLike = async () => { if (liked) return; setLiked(true); setCount((prev) => prev + 1); await fetch(`/api/posts/${postId}/like`, { method: "POST" }); }; return ( <button onClick={handleLike} aria-pressed={liked}> {liked ? "Liked" : "Like"} · {count} </button> ); }

The server fetches real data; the client component owns state and events. That division of responsibility is the entire RSC value proposition.

5. The constraint matrix: what can cross the boundary

This is where most developers hit confusion. Props crossing the RSC boundary must be serializable. Functions aren't.

typescript
// ❌ Functions aren't serializable — this breaks at the RSC boundary <ClientComponent onAction={() => console.log("clicked")} /> // ✅ Primitives and plain objects serialize cleanly <ClientComponent label="Submit" initialData={{ id: 1, name: "Aba" }} />

The escape hatch that most tutorials miss: you can pass a Server Component as children to a Client Component, because children are resolved server-side before the boundary is crossed.

typescript
// ✅ ServerSidebar is fully resolved before ClientShell receives it <ClientShell> <ServerSidebar /> </ClientShell>

This pattern lets you keep Server Components deep in a tree even when a parent is a Client Component, which is exactly how you avoid accidentally promoting server-only modules into the client bundle.

Client Shell vs Server Shell

6. When to use each

Signal

Server Component

Client Component

Fetches data from DB or external API

Yes

NO

Uses useState or useReducer

No

Yes

Accesses browser APIs (window, localStorage)

NO

Yes

Contains secrets or sensitive logic

Yes

NO

Responds to user interaction

No

Yes

Heavy dependency to keep out of the bundle

Yes

NO

Needs useEffect

No

Yes

Reads async route params (params, searchParams)

Yes (Awaited)

NO

The default in the App Router is Server Component. You opt into client rendering explicitly. That default is correct, it keeps your bundle lean unless you deliberately need interactivity.

7. The gotcha that will cost you time

Importing a Client Component into a Server Component works fine. The reverse silently breaks things:

typescript
// ❌ Importing a Server Component from inside a Client Component "use client"; import { ServerWidget } from "./server-widget"; // React will promote this to client — silently // ✅ Pass it as children instead "use client"; export function ClientShell({ children }: { children: React.ReactNode }) { return <div className="shell">{children}</div>; } // In a Server Component above: <ClientShell> <ServerWidget /> </ClientShell>

React either throws or silently ships server-only code to the browser. Neither outcome is one you want to debug in production.

TL;DR

  • In Next.js 15+, params and searchParams are Promises, await them before accessing any property. Synchronous access throws in Next.js 16.
  • The same async requirement applies to generateMetadata.
  • RSCs don't produce HTML, they produce a serialized React tree. Client components are referenced at boundaries, not inlined.
  • "use client" marks a subtree boundary, not a single component.
  • Server Components own data fetching. Client Components own state, events, and browser APIs.
  • Pass Server Components as children into Client Components instead of importing server modules into client files.

Newsletter

Get articles like this in your inbox

No spam, no noise. Just thoughtful writing on web development, design, and engineering — delivered when it matters.