React Server Components render on the server with zero client JavaScript. Here's how to use them effectively.
Server vs Client Components#
1// Server Component (default in Next.js App Router)
2// app/page.tsx
3async function Page() {
4 // Can use async/await directly
5 const data = await fetchData();
6
7 // Can access server resources
8 const config = process.env.API_KEY;
9
10 return <div>{data.title}</div>;
11}
12
13// Client Component
14// components/Counter.tsx
15'use client';
16
17import { useState } from 'react';
18
19function Counter() {
20 const [count, setCount] = useState(0);
21
22 return (
23 <button onClick={() => setCount(c => c + 1)}>
24 Count: {count}
25 </button>
26 );
27}When to Use Each#
Server Components:
✓ Fetching data
✓ Accessing backend resources
✓ Keeping sensitive data on server
✓ Large dependencies
✓ Static content
Client Components:
✓ Interactivity (onClick, onChange)
✓ useState, useEffect, useContext
✓ Browser APIs
✓ Custom hooks with state
✓ Event listeners
Data Fetching#
1// Server Component with data fetching
2async function UserProfile({ userId }: { userId: string }) {
3 const user = await prisma.user.findUnique({
4 where: { id: userId },
5 });
6
7 if (!user) {
8 notFound();
9 }
10
11 return (
12 <div>
13 <h1>{user.name}</h1>
14 <p>{user.email}</p>
15 </div>
16 );
17}
18
19// Parallel data fetching
20async function Dashboard() {
21 // These run in parallel
22 const userPromise = getUser();
23 const postsPromise = getPosts();
24 const analyticsPromise = getAnalytics();
25
26 const [user, posts, analytics] = await Promise.all([
27 userPromise,
28 postsPromise,
29 analyticsPromise,
30 ]);
31
32 return (
33 <div>
34 <UserCard user={user} />
35 <PostList posts={posts} />
36 <AnalyticsChart data={analytics} />
37 </div>
38 );
39}
40
41// Sequential when needed
42async function UserWithPosts({ userId }: { userId: string }) {
43 const user = await getUser(userId);
44 const posts = await getPostsByAuthor(user.id);
45
46 return (
47 <div>
48 <h1>{user.name}</h1>
49 <PostList posts={posts} />
50 </div>
51 );
52}Composition Patterns#
1// Server Component wrapping Client Component
2// app/page.tsx (Server)
3import { ClientSearch } from './ClientSearch';
4
5async function SearchPage() {
6 const categories = await getCategories();
7
8 return (
9 <div>
10 <h1>Search</h1>
11 {/* Pass server data to client component */}
12 <ClientSearch categories={categories} />
13 </div>
14 );
15}
16
17// components/ClientSearch.tsx (Client)
18'use client';
19
20import { useState } from 'react';
21
22export function ClientSearch({ categories }: { categories: string[] }) {
23 const [query, setQuery] = useState('');
24
25 return (
26 <div>
27 <input
28 value={query}
29 onChange={(e) => setQuery(e.target.value)}
30 />
31 <select>
32 {categories.map(cat => (
33 <option key={cat}>{cat}</option>
34 ))}
35 </select>
36 </div>
37 );
38}
39
40// Children pattern - Server components as children
41// layout.tsx (Server)
42import { Sidebar } from './Sidebar';
43
44export default function Layout({ children }) {
45 return (
46 <div className="flex">
47 <Sidebar /> {/* Server Component */}
48 <main>{children}</main>
49 </div>
50 );
51}
52
53// ClientWrapper.tsx (Client)
54'use client';
55
56export function ClientWrapper({ children }) {
57 const [isOpen, setIsOpen] = useState(true);
58
59 return (
60 <div className={isOpen ? 'expanded' : 'collapsed'}>
61 {children} {/* Server Components work here */}
62 </div>
63 );
64}Streaming and Suspense#
1import { Suspense } from 'react';
2
3// Streaming with Suspense
4async function Page() {
5 return (
6 <div>
7 {/* Renders immediately */}
8 <Header />
9
10 {/* Streams when ready */}
11 <Suspense fallback={<PostsSkeleton />}>
12 <Posts />
13 </Suspense>
14
15 {/* Streams independently */}
16 <Suspense fallback={<CommentsSkeleton />}>
17 <Comments />
18 </Suspense>
19 </div>
20 );
21}
22
23// Slow component streams later
24async function Posts() {
25 const posts = await fetchPosts(); // 2 seconds
26
27 return (
28 <ul>
29 {posts.map(post => (
30 <li key={post.id}>{post.title}</li>
31 ))}
32 </ul>
33 );
34}
35
36// Loading UI
37// app/posts/loading.tsx
38export default function Loading() {
39 return <PostsSkeleton />;
40}Caching#
1// Next.js caching
2async function getPosts() {
3 const res = await fetch('https://api.example.com/posts', {
4 cache: 'force-cache', // Default - cached
5 // cache: 'no-store', // Never cache
6 // next: { revalidate: 3600 }, // Revalidate after 1 hour
7 });
8
9 return res.json();
10}
11
12// Revalidation
13import { revalidatePath, revalidateTag } from 'next/cache';
14
15// Tag-based revalidation
16async function getPosts() {
17 const res = await fetch('https://api.example.com/posts', {
18 next: { tags: ['posts'] },
19 });
20 return res.json();
21}
22
23// Server Action to revalidate
24async function createPost(formData: FormData) {
25 'use server';
26
27 await db.post.create({ data: { ... } });
28 revalidateTag('posts');
29}
30
31// Path-based revalidation
32async function updatePost(id: string) {
33 'use server';
34
35 await db.post.update({ ... });
36 revalidatePath('/posts');
37}Server Actions#
1// Server Action in Server Component
2async function TodoList() {
3 const todos = await getTodos();
4
5 async function addTodo(formData: FormData) {
6 'use server';
7
8 const title = formData.get('title') as string;
9 await db.todo.create({ data: { title } });
10 revalidatePath('/todos');
11 }
12
13 return (
14 <div>
15 <form action={addTodo}>
16 <input name="title" />
17 <button type="submit">Add</button>
18 </form>
19 <ul>
20 {todos.map(todo => (
21 <li key={todo.id}>{todo.title}</li>
22 ))}
23 </ul>
24 </div>
25 );
26}
27
28// Server Action in Client Component
29// actions.ts
30'use server';
31
32export async function addTodo(formData: FormData) {
33 const title = formData.get('title') as string;
34 await db.todo.create({ data: { title } });
35 revalidatePath('/todos');
36}
37
38// components/AddTodoForm.tsx
39'use client';
40
41import { addTodo } from './actions';
42import { useFormStatus } from 'react-dom';
43
44function SubmitButton() {
45 const { pending } = useFormStatus();
46 return <button disabled={pending}>Add</button>;
47}
48
49export function AddTodoForm() {
50 return (
51 <form action={addTodo}>
52 <input name="title" />
53 <SubmitButton />
54 </form>
55 );
56}Error Handling#
1// error.tsx - Error boundary
2'use client';
3
4export default function Error({
5 error,
6 reset,
7}: {
8 error: Error;
9 reset: () => void;
10}) {
11 return (
12 <div>
13 <h2>Something went wrong!</h2>
14 <button onClick={() => reset()}>Try again</button>
15 </div>
16 );
17}
18
19// not-found.tsx
20export default function NotFound() {
21 return (
22 <div>
23 <h2>Not Found</h2>
24 <p>Could not find requested resource</p>
25 </div>
26 );
27}
28
29// Throwing not found
30import { notFound } from 'next/navigation';
31
32async function Page({ params }: { params: { id: string } }) {
33 const post = await getPost(params.id);
34
35 if (!post) {
36 notFound();
37 }
38
39 return <div>{post.title}</div>;
40}Best Practices#
Architecture:
✓ Default to Server Components
✓ Add 'use client' only when needed
✓ Keep client components small
✓ Lift data fetching to Server Components
Performance:
✓ Fetch data in parallel
✓ Use Suspense for streaming
✓ Cache appropriately
✓ Preload critical data
Patterns:
✓ Pass data as props to Client Components
✓ Use children for composition
✓ Co-locate Server Actions
✓ Handle errors at boundaries
Conclusion#
React Server Components enable efficient server-side rendering with zero client JavaScript for static content. Use them for data fetching and heavy computations, while reserving Client Components for interactivity. Combine with Suspense for streaming and Server Actions for mutations.