Web Development

React Server Components Explained: A Practical Guide

React Server Components explained with clear mental models and real Next.js App Router examples. AelfTech shows when to use RSC vs client components.

React Server Components changed how we reason about rendering, data, and the boundary between server and browser. If you’ve shipped a React app in the last couple of years, you’ve almost certainly bumped into react server components without fully knowing what runs where — or why your useState suddenly throws an error. This guide fixes that with a practical mental model, real code, and the trade-offs we actually weigh before reaching for them.

Why React Server Components Exist

For most of React’s history, everything was a client component. Your JavaScript shipped to the browser, hydrated, and only then could it fetch data or render meaningful content. That model works, but it carries a stubborn cost: the bundle. Every dependency you import — a date library, a Markdown parser, a syntax highlighter — gets downloaded, parsed, and executed on the user’s device, even when its only job is to produce static HTML.

React Server Components (RSC) attack that cost directly. A server component runs exclusively on the server, produces output, and sends zero JavaScript for itself to the client. The heavy marked or shiki import stays on the server. The browser receives rendered UI, not the machinery that produced it.

The payoff is measurable. On content-heavy pages we’ve migrated, moving formatting and data-shaping logic into react server components routinely trims 30–100 KB of gzipped JavaScript from the initial bundle — libraries that no longer need to cross the wire. That’s not a micro-optimization; on mid-range Android hardware, parse-and-execute time for JavaScript is often the single largest contributor to a slow first interaction.

There’s a second, subtler reason RSC exists: colocation of data. A server component can await a database query or an internal API directly in the component body — no useEffect, no loading-spinner boilerplate, no client-side waterfall. The data lives next to the UI that consumes it, and it never leaks to the browser.

Server Components vs Client Components: A Clear Mental Model

The single most useful thing you can internalize is this: components are server components by default, and you opt out into the client with the "use client" directive. This inverts the old assumption, and it trips up nearly everyone at first.

Here’s the server components vs client components split in one table:

CapabilityServer ComponentClient Component
Runs on serverYesYes (for the initial render / SSR)
Runs in browserNoYes (hydration + interactivity)
Ships JS to clientNoYes
useState, useEffect, refsNoYes
onClick / event handlersNoYes
Access DB, secrets, filesystemYesNo
async/await in bodyYesNo (use hooks / Suspense)
Browser APIs (window, localStorage)NoYes

A clean way to remember it: server components are for data and structure; client components are for interactivity and state. If a component needs to react to the user — a toggle, a form input, a modal — it belongs on the client. If it just fetches, formats, and lays out content, keep it on the server.

The "use client" directive marks a boundary, not a single file. Once a module declares "use client", every component it imports downstream becomes part of the client bundle too. So the practical rule is: push client components to the leaves of your tree. Wrap the small interactive <LikeButton />, not the entire <Article /> that contains it.

// LikeButton.jsx
"use client";

import { useState } from "react";

export function LikeButton({ initialCount }) {
  const [count, setCount] = useState(initialCount);
  return <button onClick={() => setCount((c) => c + 1)}>♥ {count}</button>;
}
// Article.jsx — no directive, so it's a Server Component
import { LikeButton } from "./LikeButton";
import { getArticle } from "@/lib/db";

export default async function Article({ slug }) {
  const article = await getArticle(slug); // runs on the server, hits the DB directly
  return (
    <article>
      <h1>{article.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: article.html }} />
      <LikeButton initialCount={article.likes} />
    </article>
  );
}

Note the composition: a server component renders a client component and passes it props. That works. What doesn’t work is the reverse — a client component can’t import and render a server component directly, because the client has no server runtime. You can, however, pass a server component as a child (via the children prop) into a client component. That’s the standard pattern for wrapping an interactive shell around server-rendered content.

How RSC Rendering and Streaming Actually Work

Understanding RSC rendering removes most of the “magic.” When a request comes in, React renders your server components on the server — but it doesn’t produce HTML strings the way old-school SSR does. It produces a serialized description of the UI, an intermediate format often called the RSC payload (the “Flight” format). This payload contains the rendered output of server components plus placeholders and references pointing to which client components need to hydrate, and with what props.

That payload gets streamed to the browser. React on the client reads it, reconstructs the component tree, and hydrates only the client components. Because server components emit no JavaScript, there’s nothing to hydrate for them — they’re already “done.”

React streaming is what makes this feel fast. Instead of blocking the whole response until every data fetch resolves, React streams the payload in chunks. Wrap a slow subtree in <Suspense>, and React sends the surrounding shell immediately with a fallback, then streams the real content into place when its data resolves.

import { Suspense } from "react";

export default function Page() {
  return (
    <main>
      <Header />              {/* fast — streams immediately */}
      <Suspense fallback={<CommentsSkeleton />}>
        <Comments />          {/* slow DB query — streams in when ready */}
      </Suspense>
    </main>
  );
}

The user sees the header and skeleton in the first flush, often within a few hundred milliseconds, while <Comments /> keeps resolving. This is a genuine improvement over the classic “await everything, then send one big HTML blob” approach, and it’s why react streaming pairs so naturally with server components. In the guides we maintain at aelftech.com, we treat Suspense boundaries as deliberate UX decisions — each one is a place you’re telling the user “this part is worth showing early.”

One clarification worth stating plainly, because it confuses people: RSC and SSR are not the same thing. SSR turns a component into an HTML string for the first paint. RSC decides whether a component ships JavaScript at all. They compose — a server component is server-rendered and ships no client JS — but they answer different questions.

Using Server Components in the Next.js App Router

The Next.js app router is the most mature production home for RSC today, and it’s where most teams will first meet them. Any component inside the app/ directory is a server component unless it opts out. Routing is file-based: a folder becomes a route segment, and a page.jsx inside it becomes the page.

// app/blog/[slug]/page.jsx — a Server Component by default
import { getPost } from "@/lib/posts";

export default async function BlogPost({ params }) {
  const post = await getPost(params.slug);
  return (
    <article>
      <h1>{post.title}</h1>
      <time>{new Date(post.date).toLocaleDateString()}</time>
      <div dangerouslySetInnerHTML={{ __html: post.contentHtml }} />
    </article>
  );
}

A few Next.js app router specifics that matter in practice:

  • layout.jsx wraps segments and persists across navigations without re-rendering — ideal for shared chrome.
  • loading.jsx is sugar for a Suspense boundary around a route, giving you instant loading UI for free.
  • error.jsx must be a client component (it needs error-boundary state) — a common gotcha.
  • Server Actions let a form call a server function directly with "use server", no API route required.
// A Server Action for a newsletter form
export default function Newsletter() {
  async function subscribe(formData) {
    "use server";
    const email = formData.get("email");
    await db.subscribers.insert({ email });
  }
  return (
    <form action={subscribe}>
      <input name="email" type="email" required />
      <button type="submit">Subscribe</button>
    </form>
  );
}

Server Actions collapse a whole category of boilerplate — no fetch handler, no manual JSON parsing, no separate route file. The team at teamaelftech.com leans on them for exactly this kind of low-ceremony mutation, though we’re deliberate about validation: an action is a public endpoint, so it needs the same input checks and auth guards you’d give any API.

Data-Fetching Patterns That Work (and Ones That Don’t)

The best thing about react server components is how boring good data fetching becomes. You await where you need the data.

Pattern that works — fetch in the component that uses it:

export default async function Dashboard() {
  const user = await getUser();          // fetched where it's rendered
  return <Greeting name={user.name} />;
}

Pattern that works — parallel fetches to avoid waterfalls. If two requests don’t depend on each other, kick them off together:

export default async function Profile({ id }) {
  const userPromise = getUser(id);
  const postsPromise = getPosts(id);
  const [user, posts] = await Promise.all([userPromise, postsPromise]);
  return <ProfileView user={user} posts={posts} />;
}

Pattern that works — request-level caching. In the Next.js app router, fetch is deduplicated within a single render pass, so calling the same endpoint from two components hits the network once. For non-fetch sources like a database client, wrap the function in React’s cache() to get the same dedup:

import { cache } from "react";

export const getUser = cache(async (id: string) => {
  return db.user.findUnique({ where: { id } });
});

Pattern that doesn’t work — fetching in a client component the RSC way. You can’t await in a client component body, and you shouldn’t recreate server waterfalls with useEffect. If data can be fetched on the server, fetch it there and pass it down as props.

Pattern to avoid — the accidental sequential waterfall. Awaiting one fetch, then a child component awaiting another that could have started earlier, serializes work that should be parallel. Hoist independent fetches up and run them with Promise.all, or use separate Suspense boundaries so slow branches stream independently.

ScenarioDoAvoid
Data used by one componentawait it right thereLifting to a global store
Two independent requestsPromise.allSequential awaits
Same data in two placescache() / fetch dedupDuplicate network calls
Client interactivity needs dataPass server data as propsuseEffect refetch

Common Pitfalls and How to Avoid Them

These are the mistakes we see most often when teams adopt react server components:

  1. Marking too much with "use client". Slapping the directive on a top-level layout drags the entire subtree into the client bundle, defeating the purpose. Fix: move the directive to the smallest interactive leaf.

  2. Importing a server-only module into a client component. If a client component imports code that touches secrets or a database, that code risks leaking into the browser bundle. Guard it with the server-only package, which throws a build error the moment such a module is pulled clientward.

  3. Passing non-serializable props across the boundary. Props from a server component to a client component must be serializable — plain objects, strings, numbers, arrays, and (usefully) Date, Map, Set, plus Server Action functions. What you cannot pass is a class instance with methods or an arbitrary callback; passing a function that isn’t a Server Action throws.

  4. Reaching for useState in a server component. The error message is clear but the cause isn’t always obvious: you forgot the "use client" directive, or you’re deeper in a server subtree than you thought.

  5. Expecting window on the server. Any browser API reference in a server component fails. Move that logic into a client component, ideally inside useEffect.

  6. Forgetting that Server Actions are public. Treat every action as an untrusted entry point. Validate inputs and check authorization inside the action body, not just in the UI.

When Not to Reach for Server Components

RSC is a tool, not a mandate. There are cases where the older approach is simply the better fit, and pretending otherwise leads to fighting the framework.

Skip or minimize server components when:

  • You’re building a highly interactive SPA-style app — a design tool, a spreadsheet, a game — where nearly everything is stateful and client-driven. The server/client boundary adds ceremony without a payoff, and a client-rendered app may be simpler.
  • You don’t run a server. RSC needs a server, or a build step that can execute your components. A purely static host with no server runtime limits what you can do dynamically, though static export still works for content that doesn’t change per request.
  • Your team is mid-migration and the mental model isn’t shared yet. The boundary rules have real cognitive cost. Dropping RSC into a large legacy client-rendered codebase all at once tends to produce confusing hybrid bugs. Migrate leaf-first, route by route.
  • Latency to your data source is the bottleneck, not bundle size. RSC won’t fix a slow database. Profile first; if the JavaScript bundle isn’t your problem, RSC isn’t your solution.

At AelfTech, our default is pragmatic: reach for server components for content, dashboards, and marketing-adjacent pages where payload and data colocation dominate, and keep genuinely interactive surfaces on the client. Most real applications are a blend, and the app router is comfortable with that.

Conclusion

React server components are best understood not as a replacement for client components but as a rebalancing — pushing data and structure to the server, keeping interactivity on the client, and letting streaming stitch the two together for a faster first experience. Get the boundary right, fetch data where you render it, and stay honest about when the model earns its complexity, and RSC becomes one of the highest-leverage tools in a modern React stack.

This guide is part of the TeamAelfTech knowledge hub, where we publish practical, production-tested engineering write-ups. For more deep dives on framework architecture, rendering strategies, and performance, explore the full Web Development category — and find the rest of our engineering library at teamaelftech.com.