Tutorial

Server Components vs Client Components in Next.js 14

Practical guide to React Server Components with decision framework and examples. When to use what—understanding the new paradigm and building optimal applications.

Next.js 14 11 min read
What You'll Learn
  • Fundamental differences between Server and Client Components
  • Decision framework for choosing the right component type
  • Composition patterns and best practices
  • Common pitfalls and how to avoid them
  • Real-world examples and use cases

The Paradigm Shift

React Server Components represent the biggest shift in React architecture since hooks. Next.js 14's App Router makes Server Components the default, fundamentally changing how we think about rendering and data fetching.

But with this power comes confusion. When should you use Server Components? When do you need Client Components? How do they compose together?

I struggled with these questions when migrating a large application to the App Router. This article distills the lessons learned into a practical decision framework.

The Fundamental Difference

Server Components

Server Components render exclusively on the server. They never execute in the browser.

Server Components Can:
  • Directly access backend resources (databases, file systems, APIs)
  • Keep sensitive code on the server (API keys, secrets)
  • Reduce JavaScript bundle size (component code not sent to client)
  • Improve initial page load performance
Server Components Cannot:
  • Use state (useState, useReducer)
  • Use effects (useEffect, useLayoutEffect)
  • Use browser-only APIs (window, document, localStorage)
  • Attach event listeners (onClick, onChange, etc.)

Client Components

Client Components work like traditional React components—they render on both server (for SSR) and client (for interactivity).

Client Components Can:
  • Use all React hooks (state, effects, context)
  • Handle user interactions and events
  • Access browser APIs
  • Use third-party libraries that depend on browser APIs
Client Components Add:
  • JavaScript to the bundle (impacts performance)
  • Hydration overhead
  • Additional network requests for data fetching

The Decision Framework

Use this flowchart to decide which component type to use:

Does the component need interactivity?
│
├─ YES → Does it need state or effects?
│  │
│  ├─ YES → Client Component
│  │
│  └─ NO → Can you lift the interactive part to a child?
│     │
│     ├─ YES → Server Component with Client child
│     │
│     └─ NO → Client Component
│
└─ NO → Does it fetch data?
   │
   ├─ YES → Server Component (direct DB/API access)
   │
   └─ NO → Server Component (default choice)

Real-World Examples

Example 1: Product Page

A product page that displays static data but has an "Add to Cart" button.

// app/products/[id]/page.tsx (Server Component)
import { db } from '@/lib/db';
import { AddToCartButton } from './AddToCartButton';

export default async function ProductPage({
  params,
}: {
  params: { id: string };
}) {
  // Direct database access - only possible in Server Components
  const product = await db.product.findUnique({
    where: { id: params.id },
    include: { reviews: true, images: true },
  });

  if (!product) {
    return <div>Product not found</div>;
  }

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <div>${product.price}</div>

      {/* Client Component for interactivity */}
      <AddToCartButton productId={product.id} />

      {/* Server Component for reviews */}
      <ReviewList reviews={product.reviews} />
    </div>
  );
}

// AddToCartButton.tsx (Client Component)
'use client';

import { useState } from 'react';
import { useCart } from '@/hooks/useCart';

export function AddToCartButton({ productId }: { productId: string }) {
  const [loading, setLoading] = useState(false);
  const { addItem } = useCart();

  async function handleClick() {
    setLoading(true);
    await addItem(productId);
    setLoading(false);
  }

  return (
    <button onClick={handleClick} disabled={loading}>
      {loading ? 'Adding...' : 'Add to Cart'}
    </button>
  );
}

Example 2: Dashboard with Real-Time Data

A dashboard that combines static data from the database with real-time updates.

// app/dashboard/page.tsx (Server Component)
import { db } from '@/lib/db';
import { getCurrentUser } from '@/lib/auth';
import { RealtimeMetrics } from './RealtimeMetrics';
import { StaticStats } from './StaticStats';

export default async function DashboardPage() {
  const user = await getCurrentUser();

  // Fetch static data on the server
  const stats = await db.stats.findMany({
    where: { userId: user.id },
    orderBy: { createdAt: 'desc' },
    take: 30,
  });

  const summary = await db.summary.findUnique({
    where: { userId: user.id },
  });

  return (
    <div>
      <h1>Welcome, {user.name}</h1>

      {/* Static data - Server Component */}
      <StaticStats summary={summary} />

      {/* Real-time updates - Client Component */}
      <RealtimeMetrics userId={user.id} initialData={stats} />
    </div>
  );
}

// RealtimeMetrics.tsx (Client Component)
'use client';

import { useEffect, useState } from 'react';
import { subscribeToUpdates } from '@/lib/websocket';

interface Props {
  userId: string;
  initialData: Metric[];
}

export function RealtimeMetrics({ userId, initialData }: Props) {
  const [metrics, setMetrics] = useState(initialData);

  useEffect(() => {
    const unsubscribe = subscribeToUpdates(userId, (newMetric) => {
      setMetrics((prev) => [newMetric, ...prev].slice(0, 30));
    });

    return unsubscribe;
  }, [userId]);

  return (
    <div>
      <h2>Real-time Metrics</h2>
      {metrics.map((metric) => (
        <MetricCard key={metric.id} metric={metric} />
      ))}
    </div>
  );
}

Example 3: Form with Validation

A form that needs client-side validation but submits via Server Actions.

// app/contact/page.tsx (Server Component)
import { ContactForm } from './ContactForm';

export default function ContactPage() {
  async function submitContact(formData: FormData) {
    'use server';

    const name = formData.get('name') as string;
    const email = formData.get('email') as string;
    const message = formData.get('message') as string;

    // Server-side validation and processing
    await db.contact.create({
      data: { name, email, message },
    });

    // Send email notification
    await sendEmail({
      to: 'support@example.com',
      subject: 'New Contact Form Submission',
      body: message,
    });
  }

  return (
    <div>
      <h1>Contact Us</h1>
      <ContactForm submitAction={submitContact} />
    </div>
  );
}

// ContactForm.tsx (Client Component)
'use client';

import { useState } from 'react';
import { useFormStatus } from 'react-dom';

interface Props {
  submitAction: (formData: FormData) => Promise<void>;
}

export function ContactForm({ submitAction }: Props) {
  const [errors, setErrors] = useState<Record<string, string>>({});

  function validate(formData: FormData) {
    const errors: Record<string, string> = {};

    const name = formData.get('name') as string;
    if (!name || name.length < 2) {
      errors.name = 'Name must be at least 2 characters';
    }

    const email = formData.get('email') as string;
    if (!email || !/\S+@\S+\.\S+/.test(email)) {
      errors.email = 'Invalid email address';
    }

    const message = formData.get('message') as string;
    if (!message || message.length < 10) {
      errors.message = 'Message must be at least 10 characters';
    }

    return errors;
  }

  async function handleSubmit(formData: FormData) {
    const validationErrors = validate(formData);

    if (Object.keys(validationErrors).length > 0) {
      setErrors(validationErrors);
      return;
    }

    setErrors({});
    await submitAction(formData);
  }

  return (
    <form action={handleSubmit}>
      <div>
        <label htmlFor="name">Name</label>
        <input type="text" id="name" name="name" required />
        {errors.name && <span className="error">{errors.name}</span>}
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input type="email" id="email" name="email" required />
        {errors.email && <span className="error">{errors.email}</span>}
      </div>

      <div>
        <label htmlFor="message">Message</label>
        <textarea id="message" name="message" required />
        {errors.message && <span className="error">{errors.message}</span>}
      </div>

      <SubmitButton />
    </form>
  );
}

function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Submitting...' : 'Submit'}
    </button>
  );
}

Composition Patterns

Pattern 1: Server Component Wrapping Client Component

The most common pattern—keep the parent as a Server Component and isolate interactivity to children.

// ✅ Good: Server Component with Client children
export default async function Page() {
  const data = await fetchData();

  return (
    <div>
      <ServerSideComponent data={data} />
      <ClientSideComponent initialData={data} />
    </div>
  );
}

Pattern 2: Passing Server Components as Props

Client Components can render Server Components passed as children or props.

// ClientWrapper.tsx
'use client';

export function ClientWrapper({ children }: { children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
      {isOpen && children}
    </div>
  );
}

// page.tsx
export default async function Page() {
  const data = await fetchData();

  return (
    <ClientWrapper>
      {/* This ServerContent stays a Server Component */}
      <ServerContent data={data} />
    </ClientWrapper>
  );
}

Pattern 3: Context Providers

Context providers must be Client Components, but you can compose them with Server Components.

// providers/theme-provider.tsx
'use client';

import { createContext, useState } from 'react';

export const ThemeContext = createContext({});

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState('light');

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

// app/layout.tsx (Server Component)
import { ThemeProvider } from '@/providers/theme-provider';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <ThemeProvider>
          {/* children can still be Server Components */}
          {children}
        </ThemeProvider>
      </body>
    </html>
  );
}

Common Pitfalls

Pitfall 1: Making Everything a Client Component

// ❌ Bad: Unnecessarily making parent a Client Component
'use client';

export default function Page() {
  const data = await fetchData(); // Error! Can't use async in Client Component

  return <div>{data}</div>;
}

// ✅ Good: Keep parent as Server Component
export default async function Page() {
  const data = await fetchData();

  return <ClientComponent data={data} />;
}

Pitfall 2: Passing Functions to Server Components

// ❌ Bad: Can't pass functions from Client to Server
'use client';

export function ClientComponent() {
  const handleClick = () => console.log('clicked');

  return <ServerComponent onClick={handleClick} />; // Error!
}

// ✅ Good: Pass functions down, not up
export default async function ServerComponent() {
  return <ClientButton />;
}

'use client';
function ClientButton() {
  const handleClick = () => console.log('clicked');
  return <button onClick={handleClick}>Click</button>;
}

Pitfall 3: Importing Client-Only Libraries in Server Components

// ❌ Bad: Importing browser-only library in Server Component
import { useLocalStorage } from 'usehooks-ts';

export default async function ServerComponent() {
  const [value] = useLocalStorage('key', 'default'); // Error!
  return <div>{value}</div>;
}

// ✅ Good: Wrap in Client Component
'use client';
import { useLocalStorage } from 'usehooks-ts';

export function StorageComponent() {
  const [value] = useLocalStorage('key', 'default');
  return <div>{value}</div>;
}

Performance Considerations

Server Components Performance Benefits
  • Zero JavaScript sent to client for non-interactive parts
  • Faster initial page loads (no hydration overhead)
  • Direct backend access eliminates API round trips
  • Automatic code splitting at component boundaries

Bundle Size Comparison

// Traditional Client Component approach
Page Bundle: 250KB (components + libraries + data fetching)

// Server Component approach
Page Bundle: 45KB (only interactive components)
Reduction: 82%

Migration Strategy

If you're migrating an existing app to the App Router:

Migration Steps
  1. Start with pages as Server Components (the default)
  2. Add 'use client' only when you hit an error
  3. Push 'use client' as deep as possible in the tree
  4. Extract interactive parts into separate Client Components
  5. Use Server Actions for mutations instead of API routes
  6. Replace useEffect data fetching with Server Component fetching

Key Takeaways

Default to Server Components. Only reach for Client Components when you need interactivity or browser APIs.

Push 'use client' down the tree. Keep parents as Server Components and isolate interactivity to small, focused children.

Server Components are not just about performance. They simplify data fetching, improve security, and reduce complexity.

Composition is key. Server and Client Components compose naturally—you just need to understand the boundaries.

Start simple. The mental model takes time to internalize. Build small, learn the patterns, then scale up.