20 minlesson

Server Component Patterns and Best Practices

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:

jsx
1// Server Component
2async function Dashboard() {
3 const data = await fetchDashboardData();
4
5 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}
14
15// Client Component
16'use client';
17function ClientTabs({ children }) {
18 const [activeTab, setActiveTab] = useState(0);
19 const tabs = Children.toArray(children);
20
21 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

jsx
1// Each component fetches its own data
2async function PostPage({ id }) {
3 return (
4 <article>
5 <PostContent id={id} />
6 <PostComments id={id} />
7 <RelatedPosts id={id} />
8 </article>
9 );
10}
11
12async function PostContent({ id }) {
13 const post = await db.posts.findById(id); // Fetch here
14 return (
15 <div>
16 <h1>{post.title}</h1>
17 <p>{post.content}</p>
18 </div>
19 );
20}
21
22async function PostComments({ id }) {
23 const comments = await db.comments.forPost(id); // Separate fetch
24 return <CommentList comments={comments} />;
25}

Parallel Data Fetching

jsx
1async function ProductPage({ id }) {
2 // Start all fetches in parallel
3 const productPromise = db.products.findById(id);
4 const reviewsPromise = db.reviews.forProduct(id);
5 const relatedPromise = db.products.related(id);
6
7 // Await all together
8 const [product, reviews, related] = await Promise.all([
9 productPromise,
10 reviewsPromise,
11 relatedPromise,
12 ]);
13
14 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

jsx
1// actions.js
2'use server';
3
4import { revalidatePath } from 'next/cache';
5import { redirect } from 'next/navigation';
6
7export async function createTodo(prevState, formData) {
8 const title = formData.get('title');
9
10 if (!title || title.length < 3) {
11 return { error: 'Title must be at least 3 characters' };
12 }
13
14 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}
22
23// Component
24'use client';
25import { useActionState } from 'react';
26import { createTodo } from './actions';
27
28function TodoForm() {
29 const [state, action, pending] = useActionState(createTodo, {});
30
31 return (
32 <form action={action}>
33 <input
34 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

jsx
1'use client';
2import { useOptimistic, useTransition } from 'react';
3import { deleteTodo } from './actions';
4
5function TodoList({ todos }) {
6 const [optimisticTodos, removeOptimistic] = useOptimistic(
7 todos,
8 (state, idToRemove) => state.filter(t => t.id !== idToRemove)
9 );
10 const [isPending, startTransition] = useTransition();
11
12 async function handleDelete(id) {
13 startTransition(async () => {
14 removeOptimistic(id);
15 await deleteTodo(id);
16 });
17 }
18
19 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

jsx
1// actions.js
2'use server';
3
4export async function submitForm(prevState, formData) {
5 try {
6 // Validate
7 const data = validateFormData(formData);
8
9 // Perform action
10 await saveToDatabase(data);
11
12 // Success
13 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.errors
22 };
23 }
24
25 // Log server-side, return safe message to client
26 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

jsx
1// error.js (Next.js convention)
2'use client';
3
4export 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}
13
14// page.js
15async function Page() {
16 const data = await riskyDatabaseCall(); // May throw
17 return <Content data={data} />;
18}

Caching and Revalidation

Request Deduplication

jsx
1// React deduplicates identical fetch requests
2async function Layout({ children }) {
3 const user = await getUser(); // Request 1
4 return (
5 <div>
6 <Header user={user} />
7 {children}
8 </div>
9 );
10}
11
12async function Page() {
13 const user = await getUser(); // Same request, deduped!
14 return <Profile user={user} />;
15}

Cache Control

jsx
1// Opt out of caching
2const data = await fetch(url, { cache: 'no-store' });
3
4// Revalidate every hour
5const data = await fetch(url, { next: { revalidate: 3600 } });
6
7// Revalidate on demand (in Server Action)
8'use server';
9import { revalidatePath, revalidateTag } from 'next/cache';
10
11export async function updatePost(id, formData) {
12 await db.posts.update(id, formData);
13
14 // Revalidate specific path
15 revalidatePath(`/posts/${id}`);
16
17 // Or revalidate by tag
18 revalidateTag('posts');
19}

Authentication Patterns

Protecting Server Components

jsx
1import { auth } from '@/lib/auth';
2import { redirect } from 'next/navigation';
3
4async function ProtectedPage() {
5 const session = await auth();
6
7 if (!session) {
8 redirect('/login');
9 }
10
11 return (
12 <div>
13 <h1>Welcome, {session.user.name}!</h1>
14 <ProtectedContent userId={session.user.id} />
15 </div>
16 );
17}

Protected Server Actions

jsx
1'use server';
2
3import { auth } from '@/lib/auth';
4
5export async function updateUserProfile(formData) {
6 const session = await auth();
7
8 if (!session) {
9 throw new Error('Unauthorized');
10 }
11
12 // Only allow users to update their own profile
13 const userId = formData.get('userId');
14 if (userId !== session.user.id) {
15 throw new Error('Forbidden');
16 }
17
18 await db.users.update(session.user.id, {
19 name: formData.get('name'),
20 email: formData.get('email'),
21 });
22
23 revalidatePath('/profile');
24}

Component Organization

File Structure

1app/
2├── layout.js # Server Component (default)
3├── page.js # Server Component
4├── error.js # Client Component (must be)
5├── loading.js # Server Component
6├── components/
7│ ├── Header.js # Server Component
8│ ├── Footer.js # Server Component
9│ ├── NavMenu.js # Client ('use client')
10│ └── SearchBar.js # Client ('use client')
11└── actions/
12 └── posts.js # Server Actions ('use server')

Naming Convention

jsx
1// Server Component - no prefix needed
2function ProductCard() { ... }
3
4// Client Component - consider suffix or directory
5'use client';
6function SearchBar() { ... }
7// or
8// components/client/SearchBar.js

Performance Patterns

Streaming with Suspense

jsx
1async function Page() {
2 return (
3 <>
4 <Header /> {/* Immediate */}
5
6 <Suspense fallback={<HeroSkeleton />}>
7 <HeroSection /> {/* Streams when ready */}
8 </Suspense>
9
10 <Suspense fallback={<ProductsSkeleton />}>
11 <FeaturedProducts /> {/* Streams when ready */}
12 </Suspense>
13
14 <Footer /> {/* Immediate */}
15 </>
16 );
17}

Lazy Loading Client Components

jsx
1import dynamic from 'next/dynamic';
2
3// Only load on client, with loading state
4const HeavyChart = dynamic(() => import('./Chart'), {
5 loading: () => <ChartSkeleton />,
6 ssr: false // Don't render on server
7});
8
9function Dashboard() {
10 return (
11 <div>
12 <Stats />
13 <HeavyChart /> {/* Loaded lazily */}
14 </div>
15 );
16}

Testing Server Components

jsx
1// Using Testing Library with Next.js
2import { render, screen } from '@testing-library/react';
3
4// Mock database/API calls
5jest.mock('@/lib/db', () => ({
6 posts: {
7 findMany: jest.fn().mockResolvedValue([
8 { id: 1, title: 'Test Post' }
9 ])
10 }
11}));
12
13test('renders posts', async () => {
14 const PostList = (await import('./PostList')).default;
15 render(await PostList());
16
17 expect(screen.getByText('Test Post')).toBeInTheDocument();
18});

Summary

Best practices for Server Components:

  1. Default to Server - Only use 'use client' when needed
  2. Pass data via props - Server to Client communication
  3. Use composition - Server children in Client parents
  4. Colocate data fetching - Each component fetches what it needs
  5. Validate Server Actions - Never trust client input
  6. Handle errors gracefully - Return safe error messages
  7. Use caching wisely - Revalidate when data changes
  8. Stream with Suspense - Progressive loading experience

Next, let's build a full-stack blog application with these patterns!