perf hacks
RenderingSSR Patterns

Server vs Client Components

The App Router changed everything. Here's what actually happens and how to avoid common mistakes.

Mental Model

Server Components are the default. They run only on the server. Client Components ('use client') still SSR, but they also hydrate and can use hooks and events.

The Two Types

// No directive = Server Component (default)

export default async function ProductList() {
  const products = await db.products.findMany();
  return <ul>{products.map(p => <li>{p.name}</li>)}</ul>;
}
  • Runs on server only
  • Can use async/await directly
  • Can access DB, filesystem, secrets
  • Cannot use useState, useEffect, onClick
'use client';

export default function AddToCart({ productId }) {
  const [loading, setLoading] = useState(false);

  return (
    <button onClick={() => setLoading(true)}>
      Add to Cart
    </button>
  );
}
  • Renders on server first (SSR), hydrates on client
  • Can use all React hooks
  • Can use browser APIs
  • Cannot be async

'use client' doesn't mean "client-only". It means "this component needs JavaScript to be interactive." It still SSRs.

The Boundary Rule

Client components cannot import Server components:

'use client';

import ServerThing from './ServerThing'; // ❌ Breaks

But Server components can render Client components:

// Server Component
import ClientButton from './ClientButton';

export default async function Page() {
  const data = await getData();
  return (
    <div>
      <h1>{data.title}</h1>
      <ClientButton /> {/* ✅ Works */}
    </div>
  );
}

The Children Pattern

Need a Client wrapper around Server content? Use children:

'use client';

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');
  return (
    <ThemeContext.Provider value={theme}>
      {children}
    </ThemeContext.Provider>
  );
}
page.tsx
export default function Page() {
  return (
    <ThemeProvider>
      <ServerContent /> {/* ✅ Works via children */}
    </ThemeProvider>
  );
}

Common Mistakes

Mistake 1: Browser APIs Without Checks

'use client';

// ❌ Breaks during SSR
const width = window.innerWidth;

// ✅ Check environment
const width = typeof window !== 'undefined' ? window.innerWidth : 0;

// ✅ Or use useEffect
const [width, setWidth] = useState(0);
useEffect(() => setWidth(window.innerWidth), []);

Mistake 2: Browser-Only Libraries

'use client';

// ❌ Library accesses window on import
import Confetti from 'react-confetti';

// ✅ Dynamic import with ssr: false
import dynamic from 'next/dynamic';
const Confetti = dynamic(() => import('react-confetti'), { ssr: false });

Mistake 3: Hydration Mismatch

'use client';

// ❌ Server and client render different values
const [time, setTime] = useState(new Date().toISOString());

// ✅ Initialize empty, set on client
const [time, setTime] = useState<string | null>(null);
useEffect(() => setTime(new Date().toISOString()), []);

Mistake 4: Unnecessary Client Components

// ❌ No interactivity - doesn't need 'use client'
'use client';

export default function ProductCard({ product }) {
  return (
    <div>
      <h2>{product.name}</h2>
      <p>{product.price}</p>
    </div>
  );
}

// ✅ Remove the directive - let it be a Server Component
export default function ProductCard({ product }) {
  return (
    <div>
      <h2>{product.name}</h2>
      <p>{product.price}</p>
    </div>
  );
}

Only add 'use client' when you need hooks or event handlers.

Decision Guide

Your Component NeedsUse
Database/API callsServer
useState, useEffectClient
onClick, onChangeClient
Just displays propsServer
Uses window/documentClient
Reads cookies/headersServer

The Composition Pattern

Keep client boundaries small:

// ❌ Entire page is client
'use client';

export default function ProductPage() {
  const [qty, setQty] = useState(1);
  // ... lots of static content ...
  return <div>...</div>;
}

// ✅ Only interactive part is client
// ProductPage.tsx (Server Component)
export default async function ProductPage() {
  const product = await getProduct();
  return (
    <div>
      <h1>{product.name}</h1>        {/* Server */}
      <p>{product.description}</p>   {/* Server */}
      <QuantityPicker />             {/* Client */}
    </div>
  );
}

// QuantityPicker.tsx
'use client';
export function QuantityPicker() {
  const [qty, setQty] = useState(1);
  return <input value={qty} onChange={e => setQty(e.target.value)} />;
}

Less client JavaScript = faster page loads.

Quick Checklist

Audit 'use client' usage Only components with interactivity (hooks, events) should have this directive.

Extract interactive parts Keep client components small. Move static content to server components.

Protect browser APIs Wrap window/document access in typeof window !== 'undefined' checks or useEffect.

Handle browser-only libraries Use dynamic import with ssr: false for libraries that access browser APIs on import.

Check for hydration errors Open the console and fix any hydration mismatch warnings.

Minimize client bundle Keep large static content in Server components. Lazy load heavy libraries.


With fundamentals covered, see Caching Strategies for Vercel-specific optimizations.

On this page