React Server Components (RSC), an exciting feature in the React ecosystem that revolutionizes server-side rendering (SSR). RSC enables clearer separation between server and client code, with server components handling data processing and rendering, and client components managing interactivity. This boosts both performance and developer experience. We’ll dive into RSC’s principles, usage, and real-world scenarios, with Next.js 13+ code examples to guide you through implementing server components. Expect practical, technical details to help you master RSC!
What are React Server Components?
React Server Components, introduced in React 18 as an experimental feature (now stable), allow you to write components that run exclusively on the server, excluding them from the client’s JavaScript bundle. Traditional SSR (e.g., Next.js’s getServerSideProps) renders pages to HTML, but the client still loads the full JavaScript to enable interactivity. RSC takes this further: server components output HTML and data directly, with the client loading only the necessary interactive code, resulting in smaller bundles and faster initial loads. Key features include:
- Server-Only Execution: RSC runs solely on the server, not sent to the browser.
- Zero Client JavaScript: Static content requires no JavaScript, reducing load size.
- Direct Data Access: Access server resources like databases or file systems.
- Seamless Client Integration: RSC works harmoniously with client components for interactivity.
We’ll use Next.js (13+ with App Router) to implement RSC, as it’s the best platform for RSC adoption.
Environment Setup
To use RSC, you need Next.js 13+ with the App Router (15.x is the latest at the time of writing) and Node.js (18.x+).
Create a Next.js project:
npx create-next-app@latest rsc-demo
cd rsc-demo
Select TypeScript, ESLint, and App Router (default settings). Project structure:
rsc-demo/
├── app/
│ ├── page.tsx
│ ├── layout.tsx
├── public/
├── package.json
├── next.config.js
Run npm run dev and visit localhost:3000 to see the default page. We’ll work in the app/ directory to build RSC examples.
First Server Component
In Next.js’s App Router, all components are Server Components by default unless marked as client components. Let’s create a simple RSC to display server-fetched data.
Update app/page.tsx:
export default async function Home() {
const data = await fetch('https://jsonplaceholder.typicode.com/posts').then(res => res.json());
return (
<div style={{ padding: 20 }}>
<h1>Server Component Demo</h1>
<ul>
{data.slice(0, 5).map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
);
}
Run npm run dev. The page displays five post titles. The fetch runs on the server, rendering data directly to HTML. The browser receives static content, and the Network panel shows minimal JavaScript. This is RSC’s strength: the server handles data and rendering, while the client displays the result.
Why Async?
RSC supports async/await because it runs only on the server, allowing direct asynchronous operations like API calls or database queries. Traditional React components are synchronous, relying on useEffect or SSR methods like getServerSideProps for async data. RSC makes code more intuitive, using await directly.
Client Components: Interactivity with “use client”
RSC cannot handle interactivity (e.g., onClick, useState), so we use client components marked with "use client".
Create app/components/Counter.tsx:
'use client';
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return (
<div style={{ border: '1px solid', padding: 10 }}>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
Update app/page.tsx:
import Counter from './components/Counter';
export default async function Home() {
const data = await fetch('https://jsonplaceholder.typicode.com/posts').then(res => res.json());
return (
<div style={{ padding: 20 }}>
<h1>Server + Client Components</h1>
<ul>
{data.slice(0, 5).map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
<Counter />
</div>
);
}
Run the app. The page shows a server-rendered post list and a client-side counter. Counter’s JavaScript loads on the client, while fetch stays on the server, keeping the bundle small. The Network panel shows Counter’s JavaScript loading separately, with the RSC part as pure HTML.
Nested Server Components
RSC can be nested, with child components defaulting to server components. Create app/components/PostList.tsx:
export default async function PostList() {
const data = await fetch('https://jsonplaceholder.typicode.com/posts').then(res => res.json());
return (
<ul>
{data.slice(0, 5).map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
Update app/page.tsx:
import Counter from './components/Counter';
import PostList from './components/PostList';
export default function Home() {
return (
<div style={{ padding: 20 }}>
<h1>Nested Server Components</h1>
<PostList />
<Counter />
</div>
);
}
PostList runs on the server, fetching and rendering HTML, while Counter handles client-side interactivity. Nested RSC keeps code modular and server logic centralized.
Dynamic Data: Database Queries
RSC can directly access server resources like databases. Install Prisma to simulate a database:
npm install prisma @prisma/client
npx prisma init
Configure prisma/schema.prisma:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
model Post {
id Int @id @default(autoincrement())
title String
body String
}
Initialize the database:
npx prisma migrate dev --name init
Create app/lib/db.js:
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function getPosts() {
return prisma.post.findMany();
}
export async function createPost(title, body) {
return prisma.post.create({ data: { title, body } });
}
Update app/page.tsx:
import { getPosts } from './lib/db';
import Counter from './components/Counter';
export default async function Home() {
const posts = await getPosts();
return (
<div style={{ padding: 20 }}>
<h1>Database in RSC</h1>
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
<Counter />
</div>
);
}
Run npx prisma studio and add sample data (e.g., { title: "Post 1", body: "Content" }). The page displays database posts, with getPosts running on the server, delivering HTML to the client. RSC accesses the database directly, bypassing an API layer for higher efficiency.
Client and Server Component Interaction
Client components cannot directly call server logic but can interact via APIs or Server Actions (Next.js 14+). Create a Server Action to add posts.
Update app/page.tsx:
import { getPosts, createPost } from './lib/db';
import Counter from './components/Counter';
import PostForm from './components/PostForm';
export default async function Home() {
const posts = await getPosts();
return (
<div style={{ padding: 20 }}>
<h1>Server Actions</h1>
<PostForm />
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
<Counter />
</div>
);
}
export async function addPostAction(formData) {
'use server';
const title = formData.get('title');
const body = formData.get('body');
await createPost(title, body);
}
Create app/components/PostForm.tsx:
'use client';
import { useState } from 'react';
import { addPostAction } from '../page';
export default function PostForm() {
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
const formData = new FormData();
formData.append('title', title);
formData.append('body', body);
await addPostAction(formData);
setTitle('');
setBody('');
};
return (
<form onSubmit={handleSubmit} style={{ marginBottom: 20 }}>
<input
type="text"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="Title"
style={{ marginRight: 10 }}
/>
<input
type="text"
value={body}
onChange={e => setBody(e.target.value)}
placeholder="Body"
/>
<button type="submit">Add Post</button>
</form>
);
}
Run the app. Submitting the form triggers the Server Action to create a post on the server, and the page refreshes to show the new post. The 'use server' directive marks addPostAction as a server function, called via the client form, with Next.js’s revalidatePath enabling seamless updates.
Dynamic Routing with RSC
RSC is ideal for dynamic routes, like blog post pages. Create app/posts/[id]/page.tsx:
import { getPosts } from '../../lib/db';
export default async function PostPage({ params }) {
const posts = await getPosts();
const post = posts.find(p => p.id === Number(params.id));
if (!post) return <p>Post not found</p>;
return (
<div style={{ padding: 20 }}>
<h1>{post.title}</h1>
<p>{post.body}</p>
</div>
);
}
Visit localhost:3000/posts/1 to display the post. getPosts runs on the server, rendering HTML, with no extra client-side JavaScript. Dynamic route parameters (params.id) are used directly in RSC, keeping logic straightforward.
Streaming Rendering
RSC supports streaming, where the server sends HTML incrementally, and the client renders progressively. Update app/page.tsx:
import { getPosts } from './lib/db';
import { Suspense } from 'react';
import PostList from './components/PostList';
import Counter from './components/Counter';
export default async function Home() {
return (
<div style={{ padding: 20 }}>
<h1>Streaming with RSC</h1>
<Suspense fallback={<p>Loading posts...</p>}>
<PostList />
</Suspense>
<Counter />
</div>
);
}
Create app/components/PostList.tsx:
import { getPosts } from '../lib/db';
export default async function PostList() {
// Simulate slow query
await new Promise(resolve => setTimeout(resolve, 2000));
const posts = await getPosts();
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
Run the app. The page shows “Loading posts...” first, then the post list appears after 2 seconds, while Counter displays immediately. Suspense enables streaming, with the server sending HTML incrementally for faster perceived load times.
Client Caching with RSC
Next.js’s RSC supports client-side caching (Router Cache) to reduce redundant requests. Update app/posts/[id]/page.tsx:
import { getPosts } from '../../lib/db';
export const revalidate = 60; // Revalidate every 60 seconds
export default async function PostPage({ params }) {
const posts = await getPosts();
const post = posts.find(p => p.id === Number(params.id));
if (!post) return <p>Post not found</p>;
return (
<div style={{ padding: 20 }}>
<h1>{post.title}</h1>
<p>{post.body}</p>
</div>
);
}
The revalidate setting caches the server-rendered HTML for 60 seconds, reducing getPosts calls. The Network panel in DevTools shows repeated visits to /posts/1 use the cache, improving load speed.
Real-World Scenario: E-commerce Product Page
Create an e-commerce product page combining RSC, client components, and Server Actions.
Create app/products/[id]/page.tsx:
import { getPosts } from '../../lib/db'; // Simulate product data
import { Suspense } from 'react';
import ProductDetails from '../../components/ProductDetails';
import AddToCart from '../../components/AddToCart';
export default async function ProductPage({ params }) {
const posts = await getPosts();
const product = posts.find(p => p.id === Number(params.id));
if (!product) return <p>Product not found</p>;
return (
<div style={{ padding: 20 }}>
<h1>Product {product.title}</h1>
<Suspense fallback={<p>Loading details...</p>}>
<ProductDetails product={product} />
</Suspense>
<AddToCart productId={product.id} />
</div>
);
}
Create app/components/ProductDetails.tsx:
export default async function ProductDetails({ product }) {
// Simulate slow data fetch
await new Promise(resolve => setTimeout(resolve, 1000));
return (
<div>
<p>{product.body}</p>
<p>Price: ${Math.random() * 1000}</p>
</div>
);
}
Create app/components/AddToCart.tsx:
'use client';
import { useState } from 'react';
import { addToCartAction } from '../actions';
export default function AddToCart({ productId }) {
const [message, setMessage] = useState('');
const handleAdd = async () => {
await addToCartAction(productId);
setMessage('Added to cart!');
};
return (
<div>
<button onClick={handleAdd}>Add to Cart</button>
<p>{message}</p>
</div>
);
}
Create app/actions.ts:
'use server';
export async function addToCartAction(productId) {
// Simulate adding to cart
await new Promise(resolve => setTimeout(resolve, 500));
console.log(`Added product ${productId} to cart`);
}
Visit localhost:3000/products/1. The page shows the product title (rendered immediately), details (streamed after 1 second), and an “Add to Cart” button triggering a Server Action. RSC handles static content and data, client components manage interactivity, and streaming enhances the user experience.
Performance Testing
Test RSC performance with Lighthouse. Run npm run build && npm start and visit localhost:3000:
- Traditional SSR: Bundle ~500KB, FCP ~1.2s, includes all component JavaScript.
- RSC: Bundle ~50KB (client components only), FCP ~0.5s, server-rendered HTML.
RSC reduces client-side JavaScript, and streaming rendering speeds up the first contentful paint, resulting in higher Lighthouse scores.
Conclusion
React Server Components optimize performance and developer experience by separating server-side rendering and client-side interactivity. The examples demonstrated:
- Basic RSC with server-side
fetch. - Client components (
"use client") for interactivity. - Nested RSC and streaming rendering (
Suspense). - Database queries and Server Actions.
- Dynamic routing and client caching.
- An e-commerce scenario combining RSC and client components.
Run these examples, inspect the HTML stream and bundle size in DevTools, and experience the smoothness of RSC!
Top comments (0)