Skip links

Server Components vs Client Components: A Practical Guide

The Mental Model Shift That Changes Everything

When React Server Components landed in production via Next.js 13, our team at Harbor Software had already been building complex dashboards and AI-powered tools for clients. We thought we understood React. We were wrong about where the boundary should live.

Article Overview

Server Components vs Client Components: A Practical Guide

13 sections · Reading flow

01
The Mental Model Shift That Changes Everything
02
What Server Components Actually Are (And Aren't)
03
Client Components: When You Need the Browser
04
The Decision Framework We Use in Production
05
The Composition Pattern: Server Wraps Client
06
Common Mistakes We've Made (So You Don't Have To)
07
Performance: The Numbers We've Measured
08
Server Actions: The Missing Piece
09
Caching and Revalidation Strategy
10
Testing Server Components
11
When to Reach for Third-Party Data Libraries
12
Architectural Boundaries for Large Teams
13
The Bottom Line

HARBOR SOFTWARE · Engineering Insights

The confusion most developers face isn’t about syntax. It’s about a fundamental shift in how you think about where code runs. For years, React lived entirely in the browser. Every component, every hook, every piece of logic shipped to the client as JavaScript. Server Components break that assumption, and breaking assumptions is always uncomfortable.

This guide is the document we wish we’d had when we started migrating our internal tools. It’s practical, opinionated, and based on real production code — not toy examples.

What Server Components Actually Are (And Aren’t)

A Server Component is a React component that runs exclusively on the server. It never ships to the client bundle. It can directly access databases, file systems, and internal APIs without exposing credentials or bloating the browser payload.

Here’s the critical distinction: Server Components are not SSR. Server-side rendering takes your client components, renders them to HTML on the server, then hydrates them in the browser. The JavaScript still ships. Server Components never hydrate. Their JavaScript stays on the server permanently.

// This is a Server Component (default in App Router)
// It runs ONLY on the server. Zero JS sent to browser.
async function ProjectList() {
  const projects = await db.project.findMany({
    where: { status: 'active' },
    include: { team: true, metrics: true }
  });

  return (
    <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
      {projects.map(project => (
        <ProjectCard key={project.id} project={project} />
      ))}
    </div>
  );
}

Notice the async keyword. Server Components can be async functions — something that was never possible with client components because React’s rendering model in the browser is synchronous. This single capability eliminates entire categories of loading state management.

Client Components: When You Need the Browser

Client Components are what you’ve been writing for years. They have access to browser APIs, can use state and effects, respond to user interactions, and update the DOM dynamically. You mark them with the 'use client' directive at the top of the file.

'use client';

import { useState, useTransition } from 'react';

export function SearchFilter({ initialResults }) {
  const [query, setQuery] = useState('');
  const [isPending, startTransition] = useTransition();
  const [results, setResults] = useState(initialResults);

  const handleSearch = (value) => {
    setQuery(value);
    startTransition(async () => {
      const filtered = await fetchResults(value);
      setResults(filtered);
    });
  };

  return (
    <div>
      <input
        type="text"
        value={query}
        onChange={(e) => handleSearch(e.target.value)}
        placeholder="Search projects..."
      />
      {isPending && <Spinner />}
      <ResultList results={results} />
    </div>
  );
}

The 'use client' directive creates a boundary. Everything imported into a client component also becomes client code. This is the source of most architectural mistakes we see in code reviews.

The Decision Framework We Use in Production

After building several production applications with Server Components — including an AI document processing pipeline and a real-time security monitoring dashboard — we developed a decision tree that our team follows:

Use a Server Component when:

  • You’re fetching data. If the component’s primary job is to fetch and display data, it belongs on the server. No loading spinners, no useEffect, no race conditions.
  • You’re accessing sensitive resources. Database queries, API keys, internal service calls — keep these on the server where credentials never touch the browser.
  • You’re rendering static or semi-static content. Marketing pages, blog posts, documentation, product listings. These don’t need interactivity at the component level.
  • You’re importing heavy libraries. Syntax highlighters, markdown parsers, date formatting libraries — if they run on the server, they don’t inflate your client bundle.

Use a Client Component when:

  • You need interactivity. Click handlers, form inputs, toggles, modals, dropdowns — anything that responds to user events.
  • You need browser APIs. localStorage, geolocation, IntersectionObserver, clipboard, Web Audio, or any other browser-specific functionality.
  • You need state. If the component manages its own state with useState, useReducer, or any state management library, it’s a client component.
  • You need effects. useEffect, useLayoutEffect, or any lifecycle-dependent behavior requires the browser.

The Composition Pattern: Server Wraps Client

The most powerful pattern in Server Components architecture is composition: Server Components that pass data down to Client Components as props. This keeps the data fetching on the server while pushing only the interactive bits to the browser.

// app/dashboard/page.tsx (Server Component)
import { DashboardCharts } from './DashboardCharts';
import { getMetrics } from '@/lib/metrics';

export default async function DashboardPage() {
  const metrics = await getMetrics();
  const alerts = await db.alert.findMany({
    where: { resolved: false },
    orderBy: { severity: 'desc' },
    take: 20
  });

  return (
    <div className="p-6">
      <h1 className="text-2xl font-bold mb-6">System Dashboard</h1>
      {/* Server-rendered static content */}
      <MetricsSummary metrics={metrics} />
      {/* Client component receives server-fetched data */}
      <DashboardCharts initialData={metrics.timeSeries} />
      {/* Another client component for interactive alert management */}
      <AlertPanel alerts={alerts} />
    </div>
  );
}
// app/dashboard/DashboardCharts.tsx
'use client';

import { useState } from 'react';
import { LineChart, Line, XAxis, YAxis } from 'recharts';

export function DashboardCharts({ initialData }) {
  const [timeRange, setTimeRange] = useState('7d');
  const [data, setData] = useState(initialData);

  // Interactive chart with client-side filtering
  return (
    <div>
      <TimeRangeSelector value={timeRange} onChange={setTimeRange} />
      <LineChart data={data}>
        <Line dataKey="requests" stroke="#3b82f6" />
        <XAxis dataKey="date" />
        <YAxis />
      </LineChart>
    </div>
  );
}

This pattern is foundational. The dashboard page itself is a Server Component that fetches everything, then delegates rendering of interactive sections to thin Client Components. The chart library (recharts) only loads in the client bundle, but the data arrives pre-fetched — no loading spinner, no waterfall.

Common Mistakes We’ve Made (So You Don’t Have To)

Mistake 1: Marking entire pages as client components

When we first migrated our project management tool, developers kept hitting errors about hooks in Server Components. The knee-jerk reaction was to slap 'use client' on the page file. This works, but it defeats the entire purpose. You lose async data fetching, you ship everything to the browser, and you’re back to the old world.

The fix: extract the interactive parts into dedicated client components and keep the page as a server component that composes them.

Mistake 2: Passing non-serializable props across the boundary

Server Components can pass props to Client Components, but those props must be serializable — plain objects, arrays, strings, numbers, booleans. You cannot pass functions, class instances, Dates (use ISO strings), or Map/Set objects.

// WRONG: Passing a function from server to client
async function Page() {
  const handleSubmit = async (data) => {
    await db.project.create({ data });
  };
  return <Form onSubmit={handleSubmit} />; // Error!
}

// RIGHT: Use Server Actions instead
async function Page() {
  async function createProject(data) {
    'use server';
    await db.project.create({ data });
  }
  return <Form action={createProject} />;
}

Mistake 3: Over-granular client boundaries

We had a developer who created a client component for every single button. Technically correct, but it created dozens of tiny client boundaries that added HTTP overhead and made the component tree hard to reason about. Better approach: group related interactive elements into a single client component that handles a logical unit of interaction.

Mistake 4: Importing server-only code in shared files

If a utility file imports from fs or @prisma/client, and a client component imports from that same utility file, the build breaks. Use the server-only package to create hard boundaries:

// lib/db-utils.ts
import 'server-only';  // Build will fail if imported from client component
import { prisma } from './prisma';

export async function getActiveProjects() {
  return prisma.project.findMany({ where: { status: 'active' } });
}

Performance: The Numbers We’ve Measured

On a client dashboard we migrated from a fully client-rendered SPA to a Server Components architecture, we measured the following improvements:

  • Initial JavaScript bundle: Reduced from 487KB to 143KB (71% reduction)
  • Time to First Contentful Paint: Dropped from 2.8s to 0.9s
  • Time to Interactive: Dropped from 4.2s to 1.4s
  • Core Web Vitals (LCP): Moved from “Needs Improvement” to “Good” range

The biggest win wasn’t any single metric — it was the elimination of loading waterfalls. In the old architecture, the page loaded, JavaScript parsed, React mounted, useEffect fired, API call went out, response came back, state updated, re-render happened. With Server Components, the data is already in the HTML when it arrives.

Server Actions: The Missing Piece

Server Actions complete the picture by giving client components a way to call server-side logic without building API routes. They’re functions marked with 'use server' that can be passed to client components and called like regular async functions.

// app/actions.ts
'use server';

import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';

export async function updateProjectStatus(projectId: string, status: string) {
  await db.project.update({
    where: { id: projectId },
    data: { status, updatedAt: new Date() }
  });

  revalidatePath('/dashboard');
}

export async function createTicket(formData: FormData) {
  const title = formData.get('title') as string;
  const priority = formData.get('priority') as string;

  const ticket = await db.ticket.create({
    data: { title, priority, status: 'open' }
  });

  redirect(`/tickets/${ticket.id}`);
}

Server Actions eliminate an entire layer of API route boilerplate. No fetch('/api/projects'), no request/response parsing, no manual error handling for HTTP status codes. The function call is the API.

Caching and Revalidation Strategy

Server Components change your caching story fundamentally. Instead of caching API responses in the browser with React Query or SWR, you cache at the server level — closer to the data source, more efficiently, and with simpler invalidation.

// Cached by default in production (static rendering)
async function BlogSidebar() {
  const recentPosts = await getRecentPosts(); // cached
  return <PostList posts={recentPosts} />;
}

// Opt out of caching for real-time data
import { unstable_noStore as noStore } from 'next/cache';

async function LiveAlerts() {
  noStore(); // Always fetch fresh
  const alerts = await getActiveAlerts();
  return <AlertList alerts={alerts} />;
}

// Time-based revalidation
async function ProductCatalog() {
  const products = await fetch('https://api.store.com/products', {
    next: { revalidate: 3600 } // Revalidate every hour
  }).then(r => r.json());

  return <ProductGrid products={products} />;
}

Testing Server Components

Testing is where the ecosystem is still maturing. Our approach at Harbor: unit test the data fetching logic separately, integration test the composed pages with Playwright, and use Storybook only for client components.

// Test the data layer independently
describe('getActiveProjects', () => {
  it('returns only active projects with team data', async () => {
    const projects = await getActiveProjects();
    expect(projects.every(p => p.status === 'active')).toBe(true);
    expect(projects[0].team).toBeDefined();
  });
});

// Integration test the full page with Playwright
test('dashboard loads with project data', async ({ page }) => {
  await page.goto('/dashboard');
  await expect(page.getByRole('heading', { name: 'System Dashboard' })).toBeVisible();
  await expect(page.getByTestId('metrics-summary')).toBeVisible();
  // Charts (client component) should also render
  await expect(page.locator('.recharts-wrapper')).toBeVisible();
});

When to Reach for Third-Party Data Libraries

A reasonable question: if Server Components handle data fetching, do you still need React Query or SWR? The answer is nuanced. For initial page loads, Server Components are superior — the data is already rendered. But for client-side mutations, polling, optimistic updates, and real-time data, React Query still has a role in Client Components.

Our rule: Server Components for the initial render, React Query inside Client Components only when you need client-side refetching or real-time updates.

Architectural Boundaries for Large Teams

For teams larger than 3-4 developers, we recommend establishing clear conventions early:

  1. Feature folders own their client boundary. Each feature directory has one or two client components that encapsulate all interactivity for that feature.
  2. Shared components declare their boundary. A shared Button component that uses onClick must be a client component. A shared Card that just renders children can stay as a server component.
  3. Data fetching lives in server components or server actions only. No fetch calls inside client components except for real-time subscriptions.
  4. Use a barrel file convention. Export client components from a client.ts barrel and server utilities from a server.ts barrel to make the boundary visible in import paths.

The Bottom Line

Server Components are not a feature you adopt incrementally and forget about. They represent a new mental model for where computation happens. The payoff — smaller bundles, faster loads, simpler data fetching, better security — is substantial, but only if you invest in understanding the boundary between server and client.

Start with pages as Server Components. Push interactivity down into leaf Client Components. Use Server Actions for mutations. Let the server do what servers do best: fetch data and render HTML. Let the browser do what browsers do best: respond to user input. That separation, once internalized, makes everything cleaner.

Leave a comment

Explore
Drag