# What You’ll Learn#
React Server Components (often searched as react server components) are one of the biggest shifts in modern React architecture: they let you render parts of your UI on the server without sending their JavaScript to the browser. This changes how you think about performance, data fetching, and component boundaries.
This guide explains RSC concepts in plain terms, shows when to use Server vs Client Components, and walks through practical examples using Next.js App Router (the most common production implementation today). If you’re new to App Router, start with our baseline primer: Getting Started with Next.js.
# Prerequisites#
| Requirement | Version | Notes |
|---|---|---|
| Node.js | 18+ (20+ recommended) | Needed for Next.js dev/build |
| Next.js | 14+ | App Router + RSC support |
| React | 18+ | RSC is a React 18-era capability |
| Basic React | — | Components, props, conditional rendering |
| Basic HTTP/data fetching | — | REST/GraphQL concepts help |
ℹ️ Note: React Server Components are a React feature, but Next.js App Router is where most teams use them today. The mental model you learn here transfers to other RSC-capable frameworks.
# React Server Components: The Mental Model#
A good way to understand RSC is to separate where a component runs from what the user sees.
What is a Server Component?#
A Server Component is a React component that runs only on the server. Its output is used to build the UI, but its code is not shipped to the browser.
That single property has two major consequences:
- 1You can safely access server-only resources (DB, secrets, internal services).
- 2You reduce the amount of JavaScript the browser has to download, parse, and execute.
What is a Client Component?#
A Client Component runs in the browser. It’s used when you need interactivity: state, effects, event handlers, and browser APIs.
In Next.js App Router, you opt into a Client Component by adding the "use client" directive at the top of the file.
RSC is not SSR (and not “just API + HTML”)#
SSR (Server-Side Rendering) generates HTML on the server for the initial load. RSC produces a server-rendered component tree that can be streamed and composed, and it enables a pattern where your data fetching and rendering logic can live “next to” your components without exposing that code to the browser.
Practically, in Next.js App Router you typically mix:
- Server Components for data fetching + rendering
- Client Components for interactive islands (filters, buttons, forms)
🎯 Key Takeaway: With RSC, default to Server Components for rendering and data access, and “bubble up” to Client Components only where user interaction requires it.
# Why React Server Components Matter (Benefits You Can Measure)#
RSC is primarily a performance and architecture feature, not a syntax feature. These are the benefits teams tend to notice in production.
1) Smaller client bundles (less JS shipped)#
Because Server Components never run on the client, their code isn’t included in the browser bundle. That reduces:
- download size (especially on mobile)
- parse/compile time
- main-thread JS execution
This matters because real-user performance is often limited by JS, not HTML. For example, Google’s Web Vitals emphasize responsiveness (INP) and rendering stability (CLS). Shipping less JavaScript typically helps reduce long tasks and improves interaction latency.
2) Easier “secure by default” data access#
Server Components can access secrets and internal networks without leaking them to the browser. That eliminates an entire class of “oops we shipped an API key” mistakes.
Examples of safe server-only work:
- querying Postgres/MySQL
- calling internal microservices
- using
process.env.*secrets - signing requests
- generating secure tokens
3) Better data-fetching ergonomics (closer to the UI)#
Instead of building a separate “data layer” that mirrors your component tree, RSC encourages fetching where you render, while still keeping it on the server.
In Next.js, you can await data directly in Server Components, then pass results down as props.
4) Streaming + Suspense for faster perceived load#
RSC works well with streaming. You can render shell UI fast, then stream in slower segments as they resolve (product lists, recommendations, comments).
This improves perceived performance, especially on slow connections or when hitting multiple backends.
💡 Tip: If you’re working on an eCommerce or content-heavy site, a common win is: render the header + product title instantly, then stream “similar items” and “reviews” behind
<Suspense>boundaries.
# Server vs Client Components: Decision Framework#
Use this table to decide where a component should live.
| Requirement | Prefer Server Component | Prefer Client Component |
|---|---|---|
Needs useState / useEffect / useReducer | ✗ | ✓ |
Needs event handlers (onClick, onSubmit) | ✗ | ✓ |
Uses browser APIs (window, document, localStorage) | ✗ | ✓ |
| Fetches data with secrets / DB access | ✓ | ✗ |
| Heavy rendering / data transformation | ✓ | ✗ (unless needed for UX) |
| SEO-critical content | ✓ | Sometimes (but usually server) |
| Reusable UI widget (pure presentational) | ✓ | ✓ (depends on interactivity) |
| Uses third-party client-only libs (charts, maps) | ✗ | ✓ |
A practical rule that works for most teams:
- Server: pages, layouts, data-loading components, content sections, lists
- Client: inputs, toggles, modals, toasts, complex interactions
⚠️ Warning: A Client Component boundary pulls all imported children into the client bundle. If you add
"use client"too high in the tree (e.g., inlayout.tsx), you can accidentally force large parts of your app to become client-side.
# Next.js App Router: RSC by Default#
In the Next.js app/ directory:
page.tsxfiles are Server Components by defaultlayout.tsxfiles are Server Components by default- You opt into client behavior using
"use client"
This encourages a default architecture where most rendering is server-side, and only interactive parts become client islands.
Project structure example#
| Path | Typical role | Server/Client |
|---|---|---|
app/products/page.tsx | Route entry, data fetching | Server |
app/products/ProductGrid.tsx | Render list of products | Server |
app/products/Filters.tsx | Interactive filters UI | Client |
app/products/ProductCard.tsx | Presentational item card | Server (unless interactive) |
# Step 1: Create a Server Component Page that Fetches Data#
This example shows a Server Component page.tsx that fetches products and renders them. In real apps you’d use your DB or internal API; here we’ll use a placeholder endpoint.
// app/products/page.tsx
type Product = { id: string; name: string; price: number };
async function getProducts(): Promise<Product[]> {
const res = await fetch("https://example.com/api/products", {
// In Next.js, fetch is server-aware; use caching intentionally.
cache: "no-store",
});
if (!res.ok) throw new Error("Failed to fetch products");
return res.json();
}
export default async function ProductsPage() {
const products = await getProducts();
return (
<main>
<h1>Products</h1>
<ul>
{products.map((p) => (
<li key={p.id}>
{p.name} — €{p.price}
</li>
))}
</ul>
</main>
);
}Why this matters: the browser receives HTML (and the RSC payload) but not the code for getProducts() and not your server secrets (if any). This is a clean separation between UI and data access.
# Step 2: Add a Client Component for Interactivity (Filters)#
Filtering is interactive: it needs state and event handlers. Keep it client-side, but don’t move the whole page to the client.
Client component: Filters.tsx#
// app/products/Filters.tsx
"use client";
import { useMemo, useState } from "react";
type Props = {
categories: string[];
onChange: (category: string | null) => void;
};
export default function Filters({ categories, onChange }: Props) {
const [selected, setSelected] = useState<string | null>(null);
const options = useMemo(() => ["All", ...categories], [categories]);
return (
<div>
{options.map((c) => {
const value = c === "All" ? null : c;
return (
<button
key={c}
onClick={() => {
setSelected(value);
onChange(value);
}}
aria-pressed={selected === value}
>
{c}
</button>
);
})}
</div>
);
}Server component page uses the client component#
In App Router, a Server Component can render a Client Component and pass serializable props (strings, numbers, arrays, plain objects).
// app/products/page.tsx
import Filters from "./Filters";
export default async function ProductsPage() {
const categories = ["Shoes", "T-Shirts", "Accessories"];
return (
<main>
<h1>Products</h1>
<Filters
categories={categories}
onChange={() => {
// You can't pass functions from server to client.
// We'll handle this properly in the next step.
}}
/>
</main>
);
}This won’t work yet because you can’t pass a function from a Server Component to a Client Component.
So how do you connect filters to server-side data? You have three common patterns:
- 1URL search params (recommended for shareable/filterable lists)
- 2Server Actions (good for mutations)
- 3Client-side fetching (only when truly needed)
Next we’ll implement the URL pattern.
# Step 3: Use URL Search Params to Bridge Client UI and Server Data#
When filters update the URL, the route re-renders on the server with new searchParams. This keeps data fetching server-side and makes the page shareable/bookmarkable.
Client component updates the URL#
// app/products/Filters.tsx
"use client";
import { useRouter, useSearchParams } from "next/navigation";
type Props = { categories: string[] };
export default function Filters({ categories }: Props) {
const router = useRouter();
const params = useSearchParams();
const active = params.get("category");
return (
<div>
<button
onClick={() => router.push("/products")}
aria-pressed={!active}
>
All
</button>
{categories.map((c) => (
<button
key={c}
onClick={() => router.push(`/products?category=${encodeURIComponent(c)}`)}
aria-pressed={active === c}
>
{c}
</button>
))}
</div>
);
}Server component uses searchParams to filter server-side#
// app/products/page.tsx
type Product = { id: string; name: string; price: number; category: string };
async function getProducts(category?: string | null): Promise<Product[]> {
const url = new URL("https://example.com/api/products");
if (category) url.searchParams.set("category", category);
const res = await fetch(url.toString(), { cache: "no-store" });
if (!res.ok) throw new Error("Failed to fetch products");
return res.json();
}
export default async function ProductsPage({
searchParams,
}: {
searchParams: Promise<{ category?: string }>;
}) {
const { category } = await searchParams;
const products = await getProducts(category);
return (
<main>
<h1>Products</h1>
<p>Category: {category ?? "All"}</p>
<ul>
{products.map((p) => (
<li key={p.id}>
{p.name} — €{p.price}
</li>
))}
</ul>
</main>
);
}Why this matters: you get interactivity without giving up server-side data fetching, and you avoid shipping your fetching logic to the client.
# Step 4: Use Suspense + Streaming for Heavy Sections#
RSC shines when you split slow parts of the page into independently loading sections. In Next.js App Router, you can do this with Suspense.
Example: stream recommendations separately#
// app/products/Recommendations.tsx
type Product = { id: string; name: string };
async function getRecommendations(): Promise<Product[]> {
const res = await fetch("https://example.com/api/recommendations", {
cache: "no-store",
});
if (!res.ok) throw new Error("Failed to fetch recommendations");
return res.json();
}
export default async function Recommendations() {
const items = await getRecommendations();
return (
<aside>
<h2>Recommended</h2>
<ul>
{items.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
</aside>
);
}// app/products/page.tsx
import { Suspense } from "react";
import Recommendations from "./Recommendations";
export default async function ProductsPage() {
return (
<main>
<h1>Products</h1>
<Suspense fallback={<p>Loading recommendations…</p>}>
<Recommendations />
</Suspense>
</main>
);
}This matters because users can start reading and interacting with the main content while secondary content streams in, which often improves perceived speed on slower networks.
💡 Tip: Put your slowest backend calls behind a Suspense boundary, and keep your above-the-fold content in a fast Server Component. This usually improves real-world engagement more than micro-optimizing client-side code.
# Step 5: Server Actions (When You Need Mutations)#
Filtering is best via URL params, but form submissions and mutations are often cleaner with Server Actions. They let you run server code from a form without creating a separate API route.
Example: add-to-wishlist action#
// app/products/actions.ts
"use server";
export async function addToWishlist(productId: string) {
// Safe: server-only logic (DB call, internal API, auth checks)
await fetch("https://example.com/api/wishlist", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ productId }),
});
}// app/products/AddToWishlistButton.tsx
"use client";
import { useTransition } from "react";
import { addToWishlist } from "./actions";
export default function AddToWishlistButton({ productId }: { productId: string }) {
const [pending, startTransition] = useTransition();
return (
<button
disabled={pending}
onClick={() => startTransition(() => addToWishlist(productId))}
>
{pending ? "Saving…" : "Add to wishlist"}
</button>
);
}Why this matters: the mutation logic stays on the server, you don’t ship credentials, and you reduce API boilerplate. Use this for write operations: create/update/delete, subscriptions, contact forms, etc.
⚠️ Warning: Don’t treat Server Actions as “free security.” You still need authentication, authorization, and input validation on the server side, exactly like you would for an API endpoint.
# Common RSC Constraints (And How to Design Around Them)#
RSC is powerful, but it has sharp edges. Most production issues come from misunderstanding the constraints.
1) You can’t use hooks in Server Components#
Hooks like useState, useEffect, and useRef require the browser runtime. If you need them, isolate that part into a Client Component.
2) You can’t pass functions from Server to Client Components#
Server Components can pass serializable props only. Use one of these patterns instead:
- URL params for filtering/sorting/pagination
- Server Actions for mutations
- Client fetch for fully client-driven UX (last resort)
3) Client boundaries can accidentally balloon your bundle#
A "use client" at the wrong place can turn large sections into client code. Keep client components small and leaf-level.
4) Third-party libraries may force Client Components#
Many UI libraries depend on browser APIs. Wrap them in small client components and pass in server-fetched data as props.
# Practical Architecture Pattern: “Server Shell + Client Islands”#
Here’s a pattern that works well for dashboards, marketplaces, and SaaS:
- 1Page and major sections are Server Components
- 2Each interactive widget is a Client Component
- 3Data fetching happens on the server, then gets passed down
| Page part | Example | Component type |
|---|---|---|
| Header, navigation, breadcrumbs | Static or user-aware UI | Server |
| Data table rows | 100–5,000 rows server-rendered with pagination | Server |
| Column sorting UI | Button group, dropdown | Client |
| Row actions | “Edit”, “Archive”, modal | Client |
| Mutations | Create/update/delete | Server Actions |
This matters because you can keep your Time to Interactive low while still delivering rich UX.
If you’re building a React/Next.js product and want this pattern implemented cleanly with performance budgets and analytics, that’s exactly what we do at Samioda: web & mobile development.
# Caching and Revalidation: Don’t Accidentally DDOS Yourself#
With App Router, fetch() integrates with Next’s caching and can be revalidated. You need a conscious strategy, otherwise you’ll either serve stale content or overload your backend.
Common caching modes in Next.js (conceptual)#
| Use case | Suggested setting | Why |
|---|---|---|
| Always-fresh data (prices, inventory) | cache: "no-store" | Avoid stale results |
| Mostly static content (blog, docs) | default caching or revalidate | Reduce backend load |
| “Fresh enough” data (catalog updates) | revalidate every X seconds | Balance freshness and cost |
A simple rule: if showing stale data can cause user harm (wrong price, wrong availability), default to no-store or short revalidation.
# Common Pitfalls (And How to Avoid Them)#
- 1Marking entire routes as client — Keep
"use client"at the smallest possible component boundary to avoid bundle bloat. - 2Fetching on the client by default — Prefer server fetching for initial render; move to client only for live-updating UX.
- 3Trying to use browser APIs in Server Components — If you need
localStorageorwindow, isolate that logic into a Client Component. - 4Ignoring streaming opportunities — Use
<Suspense>around slow sections like recommendations, reviews, or analytics widgets. - 5Mixing mutation logic into client code — Prefer Server Actions for writes to keep security checks server-side.
For a solid App Router foundation before you refactor toward RSC-first patterns, review: Getting Started with Next.js.
# Key Takeaways#
- Default to Server Components for data fetching, rendering, and secure access to secrets/DB; ship less JavaScript to the browser.
- Use Client Components only for interactivity (state, effects, handlers) and keep them small to control bundle size.
- Connect client UI to server data using URL search params for filters/sorting/pagination; use Server Actions for mutations.
- Improve perceived performance by streaming slow sections behind Suspense boundaries instead of blocking the whole page.
- Treat caching as a product decision:
no-storefor critical freshness, revalidation for content that can tolerate minor staleness.
# Conclusion#
React Server Components change the default: your React app can be server-first, secure by default, and significantly lighter on client JavaScript, while still supporting interactive UX through small client islands. In Next.js App Router, you get a production-ready RSC workflow today by keeping pages and data on the server, then opting into "use client" only where it pays off.
If you want help designing an RSC architecture, performance budget, and migration plan (App Router, Server Actions, streaming, analytics), talk to Samioda: web & mobile development.
FAQ
More in Web Development
All →Technical SEO for Developers (Next.js): Everything You Need to Know in 2026
A practical, developer-focused guide to technical SEO in Next.js: meta tags, structured data, Core Web Vitals, sitemaps, robots.txt, canonical URLs, and production-ready examples.
API Integration Guide: Best Practices for 2026
A practical API integration guide for 2026: REST vs GraphQL, authentication, error handling, retries, rate limiting, and production-ready Next.js API route examples.
Best Headless CMS in 2026: Sanity vs Strapi vs Contentful (and 2 More)
A practical 2026 comparison of the top 5 headless CMS options—Sanity, Strapi, Contentful, Directus, and Storyblok—focused on developer experience, Next.js integration, features, and pricing.
Need help with your project?
We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.
Related Articles
Progressive Web Apps (PWA): Complete Guide for 2026
A practical progressive web app PWA guide for 2026: concepts, business benefits vs native apps, and a step-by-step Next.js implementation with manifest and service worker code.
Why Next.js Is the Best Framework for SEO in 2026
Learn why Next.js dominates SEO performance in 2026. Server-side rendering, Core Web Vitals, structured data, and real performance comparisons.
Getting Started with Next.js: A Complete Guide for 2026
Learn how to build modern web applications with Next.js — from project setup, routing, and data fetching to deployment and performance optimization.