React Server Components Tutorial: Build Faster Apps with Less Client JavaScript
Learn React Server Components with Next.js and React Router: server-first data, client boundaries, caching, mutations, and security in 2026.
Image used for representation purposes only.
What React Server Components Are (and aren’t)
React Server Components (RSC) are components that run on the server—either at build time or per-request—and send their rendered results to the browser. They can await data during render, drastically reduce the JavaScript shipped to the client, and compose seamlessly with traditional Client Components for interactivity. There is no “use server” directive for components themselves; you add interactivity by nesting a Client Component marked with “use client”, and you define callable Server Functions with the “use server” directive. (react.dev )
RSC landed as a stable part of the React 19 architecture alongside features like Actions and the useOptimistic hook that improve mutations and per-interaction UX. Together, these capabilities enable server-first data pipelines with progressive enhancement for forms. (react.dev )
Why use RSC
- Smaller client bundles: heavy data/formatting libraries stay on the server.
- Faster first render: the server can stream markup and data to the client through Suspense.
- Simpler data access: read from databases or services directly in server code, without a bespoke API layer for every screen. (react.dev )
RSC vs. SSR (and how they fit together)
SSR renders Client Components on the server to HTML, then hydrates them in the browser. RSC render in a separate server environment and stream their results; Client Components still hydrate where needed. In practice you’ll often use both: render a server-heavy tree, and hydrate only the interactive leaves. (react.dev )
Choosing a setup in 2026
- Next.js App Router (most common): RSC by default, explicit client boundaries via “use client”, Server Functions via “use server”, and first-class caching tools. (nextjs.org )
- React Router with Vite: React Router provides RSC documentation/templates; Vite offers official RSC support via @vitejs/plugin-rsc. This is a solid choice if you prefer a lighter stack. (reactrouter.com )
The rest of this tutorial will use Next.js for concreteness, with notes for React Router where useful.
Project setup (Next.js)
- Create the app (App Router enabled by default):
npx create-next-app@latest my-rsc-app
cd my-rsc-app
pnpm dev # or npm/yarn
- Create your first Server Component page that fetches data during render:
// app/users/page.tsx (Server Component by default)
import { Suspense } from 'react'
async function getUsers() {
const res = await fetch('https://api.example.com/users', {
// Configure caching per request when appropriate
// next: { revalidate: 3600 },
})
if (!res.ok) throw new Error('Failed to load users')
return res.json() as Promise<Array<{ id: string; name: string }>>
}
export default async function UsersPage() {
const users = await getUsers()
return (
<div>
<h1>Users</h1>
<Suspense fallback={<p>Loading…</p>}>
<ul>
{users.map(u => (
<li key={u.id}>{u.name}</li>
))}
</ul>
</Suspense>
</div>
)
}
- Async server components can await during render; use Suspense for streaming and progressive disclosure. (react.dev )
Adding interactivity with a Client Component
Create a client-side LikeButton and compose it inside a Server Component.
// components/LikeButton.tsx
"use client"
import { useTransition } from 'react'
import { likeUser } from '../server-actions/likes'
export default function LikeButton({ userId }: { userId: string }) {
const [pending, start] = useTransition()
return (
<button disabled={pending} onClick={() => start(() => likeUser(userId))}>
{pending ? 'Liking…' : 'Like'}
</button>
)
}
// server-actions/likes.ts (Server Functions live in a server-only module)
"use server"
import { revalidatePath } from 'next/cache'
export async function likeUser(userId: string) {
// Mutate on the server (e.g., DB write); never expose secrets to the client
await db.likes.insert({ userId })
// Invalidate caches for pages that show likes
revalidatePath('/users')
}
-
Place “use server” at the top of a dedicated file; import it from a Client Component and call it. Next.js generates the server endpoint and wires the call securely. Use revalidatePath/revalidateTag to keep server-rendered views fresh after mutations. (nextjs.org )
-
In your Server Component page, compose the button:
// app/users/page.tsx (excerpt)
import LikeButton from '@/components/LikeButton'
export default async function UsersPage() {
const users = await getUsers()
return (
<ul>
{users.map(u => (
<li key={u.id}>
{u.name} <LikeButton userId={u.id} />
</li>
))}
</ul>
)
}
- Server Functions are part of React’s RSC model; when passed to form/action props they behave as Server Actions with progressive enhancement. (react.dev )
Progressive enhancement with forms
You can submit to a Server Function without client JavaScript by passing it as a form action.
// app/users/NewUserForm.tsx
"use client"
import { useFormStatus } from 'react-dom'
import { createUser } from '@/server-actions/users'
function Submit() {
const { pending } = useFormStatus()
return <button disabled={pending}>{pending ? 'Saving…' : 'Create'}</button>
}
export default function NewUserForm() {
return (
<form action={createUser}>
<input name="name" placeholder="Name" required />
<Submit />
</form>
)
}
This works with plain HTML forms and enhances automatically on the client, while still executing the mutation securely on the server. (react.dev )
Caching and revalidation you’ll actually understand
Next.js exposes explicit, composable caching for RSC through Cache Components and the “use cache” directive. You can cache at the component or function level and control invalidation with tags or paths. This makes data freshness predictable and decouples caching from fetch-only scenarios. (nextjs.org )
Examples:
// app/components/Sidebar.tsx
'use cache' // Cache the return value of this component
export default async function Sidebar() {
const nav = await getNavigation()
return <Nav items={nav} />
}
// Using cache tags for selective invalidation in a server function
"use server"
import { revalidateTag, cacheTag } from 'next/cache'
export async function createPost(formData: FormData) {
await db.posts.insert({ title: formData.get('title') })
revalidateTag('posts')
}
export async function getPosts() {
cacheTag('posts')
return db.posts.all()
}
- Use revalidatePath for route-level refreshes; use revalidateTag/cacheTag for fine-grained, cross-route invalidation. (nextjs.org )
Serialization rules and safe boundaries
When data crosses the server→client boundary (e.g., props passed from an RSC to a Client Component), it must be serializable. Don’t pass class instances, functions, or non-JSON-ish values. Keep secrets and tokens in server-only code; never leak them via props. Interactivity (state/effects) belongs in Client Components; RSC remain pure and stateless. (react.dev )
Bad boundary (don’t do this):
// ❌ Passing a function to a Client Component from an RSC
export default async function UsersPage() {
const users = await db.users.all()
function onSelect(u: User) { /* server-only context here — wrong place */ }
return <UserList users={users} onSelect={onSelect} /> // will error
}
Prefer this split:
// ✅ RSC supplies serializable data only
export default async function UsersPage() {
const users = await db.users.all()
return <UserList users={users} />
}
// components/UserList.tsx
"use client"
export default function UserList({ users }) {
const [selected, setSelected] = useState<string | null>(null)
return (
<ul>
{users.map((u) => (
<li key={u.id}>
<button onClick={() => setSelected(u.id)}>{u.name}</button>
</li>
))}
</ul>
)
}
React Router + Vite notes
Prefer a lighter stack? React Router documents how to build with RSC (Data Mode and Framework Mode). Pair it with Vite’s official RSC plugin to get a dev server and bundling that understand the RSC Flight protocol. Skim their guides/templates for the latest project bootstrap commands. (reactrouter.com )
Performance checklist
- Stream with Suspense: start lower-priority data on the server and “use” it in a client subtree for progressive rendering. (react.dev )
- Shrink bundles: move markdown parsing, data shaping, and other heavy work into RSC.
- Co-locate data: fetch where you render to avoid waterfalls and needless JSON hops. (react.dev )
- Make caching explicit: use “use cache” plus tags/paths so refreshes are deterministic. (nextjs.org )
Debugging and guardrails
- “Only serializable values” errors usually mean you passed a function, class, or special value (like a Symbol) through props. Refactor the boundary to pass primitives/POJOs only. (react.dev )
- Stale data after mutations? Ensure your mutation runs in a Server Function and calls revalidatePath or revalidateTag for the affected views. (nextjs.org )
- Avoid client-only hooks in RSC; put state/effects in Client Components and feed them server-fetched data via props. (react.dev )
Security: patch proactively
In December 2025, the React team disclosed CVE‑2025‑55182 (“React2Shell”), a critical RCE affecting the RSC Flight protocol across multiple frameworks and bundlers. Follow the official advisories and upgrade React and any RSC-enabled framework/bundler to patched versions. Additional advisories in January 2026 covered DoS and source code exposure; be sure to track and apply the recommended fixes. (react.dev )
Practical steps:
- Keep React, react-server-dom-* packages, and your framework (Next.js/React Router/Vite plugin) on the latest patched minor.
- Treat Server Functions as sensitive endpoints; validate inputs, authenticate where appropriate, and limit their surface area.
When RSC may not be the right tool
- Highly interactive widgets that rely on frequent local state updates might not benefit from moving logic server-side.
- Simple SPAs without server rendering or data-fetching can remain client-only.
- Legacy UI libraries that depend on runtime DOM access may require client boundaries around most of their components.
Summary
RSC let you move expensive data work to the server, ship less JavaScript, and keep interactivity where it belongs—in small, focused Client Components. Start with a server-first page, add “use client” only when you truly need browser APIs, and make caching/revalidation explicit. With these patterns, you’ll deliver faster apps with a simpler mental model in 2026.
Related Posts
Build a GraphQL API and React Client: An End‑to‑End Tutorial
Learn GraphQL with React and Apollo Client by building a full stack app with queries, mutations, caching, and pagination—step by step.
React AI Chatbot Tutorial: Build a Streaming Chat UI with OpenAI and Node.js
Build a streaming React AI chatbot with a secure Node proxy using OpenAI’s Responses API. Code, SSE streaming, model tips, and production guidance.
OpenAI API with React: A 2026 Guide to Chat, Tools, and Realtime Voice
A practical 2026 guide to building React apps on the OpenAI API: secure setup, typed streaming, tools/function calling, and live voice with Realtime.