Server Component Patterns and Best Practices
Let's explore patterns for building effective applications with Server and Client Components.
Composition Pattern
Pass Server Components as children to Client Components:
jsx1// Server Component2async function Dashboard() {3 const data = await fetchDashboardData();45 return (6 <ClientTabs>7 {/* Server components as children */}8 <Overview data={data.overview} />9 <Analytics data={data.analytics} />10 <Reports data={data.reports} />11 </ClientTabs>12 );13}1415// Client Component16'use client';17function ClientTabs({ children }) {18 const [activeTab, setActiveTab] = useState(0);19 const tabs = Children.toArray(children);2021 return (22 <div>23 <nav>24 {tabs.map((_, i) => (25 <button key={i} onClick={() => setActiveTab(i)}>26 Tab {i + 1}27 </button>28 ))}29 </nav>30 <div>{tabs[activeTab]}</div>31 </div>32 );33}
Why this works: Server Components are pre-rendered as props, Client Component handles interactivity.
Data Fetching Patterns
Colocate Data with Components
jsx1// Each component fetches its own data2async function PostPage({ id }) {3 return (4 <article>5 <PostContent id={id} />6 <PostComments id={id} />7 <RelatedPosts id={id} />8 </article>9 );10}1112async function PostContent({ id }) {13 const post = await db.posts.findById(id); // Fetch here14 return (15 <div>16 <h1>{post.title}</h1>17 <p>{post.content}</p>18 </div>19 );20}2122async function PostComments({ id }) {23 const comments = await db.comments.forPost(id); // Separate fetch24 return <CommentList comments={comments} />;25}
Parallel Data Fetching
jsx1async function ProductPage({ id }) {2 // Start all fetches in parallel3 const productPromise = db.products.findById(id);4 const reviewsPromise = db.reviews.forProduct(id);5 const relatedPromise = db.products.related(id);67 // Await all together8 const [product, reviews, related] = await Promise.all([9 productPromise,10 reviewsPromise,11 relatedPromise,12 ]);1314 return (15 <div>16 <ProductDetails product={product} />17 <Reviews reviews={reviews} />18 <RelatedProducts products={related} />19 </div>20 );21}
Server Action Patterns
Form with Server Action
jsx1// actions.js2'use server';34import { revalidatePath } from 'next/cache';5import { redirect } from 'next/navigation';67export async function createTodo(prevState, formData) {8 const title = formData.get('title');910 if (!title || title.length < 3) {11 return { error: 'Title must be at least 3 characters' };12 }1314 try {15 await db.todos.create({ title, completed: false });16 revalidatePath('/todos');17 return { success: true, message: 'Todo created!' };18 } catch (e) {19 return { error: 'Failed to create todo' };20 }21}2223// Component24'use client';25import { useActionState } from 'react';26import { createTodo } from './actions';2728function TodoForm() {29 const [state, action, pending] = useActionState(createTodo, {});3031 return (32 <form action={action}>33 <input34 name="title"35 placeholder="New todo..."36 disabled={pending}37 />38 <button disabled={pending}>39 {pending ? 'Adding...' : 'Add'}40 </button>41 {state.error && <p className="error">{state.error}</p>}42 {state.success && <p className="success">{state.success}</p>}43 </form>44 );45}
Mutation with Optimistic Update
jsx1'use client';2import { useOptimistic, useTransition } from 'react';3import { deleteTodo } from './actions';45function TodoList({ todos }) {6 const [optimisticTodos, removeOptimistic] = useOptimistic(7 todos,8 (state, idToRemove) => state.filter(t => t.id !== idToRemove)9 );10 const [isPending, startTransition] = useTransition();1112 async function handleDelete(id) {13 startTransition(async () => {14 removeOptimistic(id);15 await deleteTodo(id);16 });17 }1819 return (20 <ul>21 {optimisticTodos.map(todo => (22 <li key={todo.id}>23 {todo.title}24 <button onClick={() => handleDelete(todo.id)}>Delete</button>25 </li>26 ))}27 </ul>28 );29}
Error Handling Patterns
Server Action Error Handling
jsx1// actions.js2'use server';34export async function submitForm(prevState, formData) {5 try {6 // Validate7 const data = validateFormData(formData);89 // Perform action10 await saveToDatabase(data);1112 // Success13 return {14 status: 'success',15 message: 'Form submitted successfully!'16 };17 } catch (error) {18 if (error instanceof ValidationError) {19 return {20 status: 'validation_error',21 errors: error.errors22 };23 }2425 // Log server-side, return safe message to client26 console.error('Form submission failed:', error);27 return {28 status: 'error',29 message: 'Something went wrong. Please try again.'30 };31 }32}
Error Boundary with Server Components
jsx1// error.js (Next.js convention)2'use client';34export default function Error({ error, reset }) {5 return (6 <div className="error-page">7 <h2>Something went wrong!</h2>8 <p>{error.message}</p>9 <button onClick={reset}>Try again</button>10 </div>11 );12}1314// page.js15async function Page() {16 const data = await riskyDatabaseCall(); // May throw17 return <Content data={data} />;18}
Caching and Revalidation
Request Deduplication
jsx1// React deduplicates identical fetch requests2async function Layout({ children }) {3 const user = await getUser(); // Request 14 return (5 <div>6 <Header user={user} />7 {children}8 </div>9 );10}1112async function Page() {13 const user = await getUser(); // Same request, deduped!14 return <Profile user={user} />;15}
Cache Control
jsx1// Opt out of caching2const data = await fetch(url, { cache: 'no-store' });34// Revalidate every hour5const data = await fetch(url, { next: { revalidate: 3600 } });67// Revalidate on demand (in Server Action)8'use server';9import { revalidatePath, revalidateTag } from 'next/cache';1011export async function updatePost(id, formData) {12 await db.posts.update(id, formData);1314 // Revalidate specific path15 revalidatePath(`/posts/${id}`);1617 // Or revalidate by tag18 revalidateTag('posts');19}
Authentication Patterns
Protecting Server Components
jsx1import { auth } from '@/lib/auth';2import { redirect } from 'next/navigation';34async function ProtectedPage() {5 const session = await auth();67 if (!session) {8 redirect('/login');9 }1011 return (12 <div>13 <h1>Welcome, {session.user.name}!</h1>14 <ProtectedContent userId={session.user.id} />15 </div>16 );17}
Protected Server Actions
jsx1'use server';23import { auth } from '@/lib/auth';45export async function updateUserProfile(formData) {6 const session = await auth();78 if (!session) {9 throw new Error('Unauthorized');10 }1112 // Only allow users to update their own profile13 const userId = formData.get('userId');14 if (userId !== session.user.id) {15 throw new Error('Forbidden');16 }1718 await db.users.update(session.user.id, {19 name: formData.get('name'),20 email: formData.get('email'),21 });2223 revalidatePath('/profile');24}
Component Organization
File Structure
1app/2├── layout.js # Server Component (default)3├── page.js # Server Component4├── error.js # Client Component (must be)5├── loading.js # Server Component6├── components/7│ ├── Header.js # Server Component8│ ├── Footer.js # Server Component9│ ├── NavMenu.js # Client ('use client')10│ └── SearchBar.js # Client ('use client')11└── actions/12 └── posts.js # Server Actions ('use server')
Naming Convention
jsx1// Server Component - no prefix needed2function ProductCard() { ... }34// Client Component - consider suffix or directory5'use client';6function SearchBar() { ... }7// or8// components/client/SearchBar.js
Performance Patterns
Streaming with Suspense
jsx1async function Page() {2 return (3 <>4 <Header /> {/* Immediate */}56 <Suspense fallback={<HeroSkeleton />}>7 <HeroSection /> {/* Streams when ready */}8 </Suspense>910 <Suspense fallback={<ProductsSkeleton />}>11 <FeaturedProducts /> {/* Streams when ready */}12 </Suspense>1314 <Footer /> {/* Immediate */}15 </>16 );17}
Lazy Loading Client Components
jsx1import dynamic from 'next/dynamic';23// Only load on client, with loading state4const HeavyChart = dynamic(() => import('./Chart'), {5 loading: () => <ChartSkeleton />,6 ssr: false // Don't render on server7});89function Dashboard() {10 return (11 <div>12 <Stats />13 <HeavyChart /> {/* Loaded lazily */}14 </div>15 );16}
Testing Server Components
jsx1// Using Testing Library with Next.js2import { render, screen } from '@testing-library/react';34// Mock database/API calls5jest.mock('@/lib/db', () => ({6 posts: {7 findMany: jest.fn().mockResolvedValue([8 { id: 1, title: 'Test Post' }9 ])10 }11}));1213test('renders posts', async () => {14 const PostList = (await import('./PostList')).default;15 render(await PostList());1617 expect(screen.getByText('Test Post')).toBeInTheDocument();18});
Summary
Best practices for Server Components:
- Default to Server - Only use 'use client' when needed
- Pass data via props - Server to Client communication
- Use composition - Server children in Client parents
- Colocate data fetching - Each component fetches what it needs
- Validate Server Actions - Never trust client input
- Handle errors gracefully - Return safe error messages
- Use caching wisely - Revalidate when data changes
- Stream with Suspense - Progressive loading experience
Next, let's build a full-stack blog application with these patterns!