Week 10

Refs

Custom hooks

React 19

React Server Components

Practice

Assignment

Front end Track

React Server Components

Every data-fetching pattern you've learned so far runs in the browser. The component renders, mounts, runs an effect, fires a fetch request, waits for a response, updates state, and re-renders. That sequence works, but it has real costs.

The user sees nothing useful until JavaScript has loaded, the component has rendered, and the fetch has completed. On slow connections or low-powered devices, that gap is noticeable. And every piece of data-fetching logic (the loading states, the error handling) ends up in the bundle shipped to every user, even though none of it produces visible UI.

React Server Components address this at the architecture level. Rather than fetching data after the page loads, Server Components fetch data before the page reaches the browser: on the server, during rendering. The finished HTML arrives ready to display. No loading spinner required.

The Server vs Client Paradigm

Before Server Components, React was entirely client-side. Every component ran in the browser. The server's job was to deliver an HTML shell and a JavaScript bundle; React took over from there.

Server Components introduce a new model: some components run on the server and some run in the browser, and they compose together in the same component tree.

This isn't like server-side rendering (SSR), which renders components on the server but then ships the same components to the browser to "hydrate." Server Components genuinely never reach the browser. Their code isn't in the bundle. They can access server resources directly (databases, filesystems, environment variables) and send only the rendered output to the client.

The mental model shift: instead of thinking about "when does this fetch?" you think about "where does this component run?"

How Server Components Work

A Server Component is an async function that returns JSX. It runs on the server during the request, can await anything it needs, and sends the result to the browser as rendered output.

// This component runs on the server — never in the browser
// No 'use client' directive = Server Component by default in Next.js

async function ProductList() {
  // Direct fetch — no useEffect, no useState, no loading state
  const response = await fetch('<https://api.example.com/products>');
  const products = await response.json();

  return (
    <ul>
      {products.map((product: Product) => (
        <li key={product.id}>
          <h2>{product.name}</h2>
          <p>€{product.price}</p>
        </li>
      ))}
    </ul>
  );
}

When a user visits the page, the server runs this function, fetches the products, renders the list to HTML, and sends it down. The browser displays the list immediately. There's no client-side JavaScript for this component — nothing to download, parse, or execute.

<aside> 💡

Info

In Next.js with the App Router, every component is a Server Component by default. You opt into being a Client Component by adding 'use client' at the top of the file. This is the opposite of what you might expect — the default is the more capable option, and you add a directive when you need browser-specific features.

</aside>

Server Components vs Client Components

The distinction comes down to where the component runs and what it has access to.

// No directive = Server Component
// Runs on the server during the request

async function UserProfile({ userId }: { userId: string }) {
  // Can do any of these:
  const user = await db.users.findById(userId);        // direct database access
  const secret = process.env.API_SECRET_KEY;           // server-only env vars
  const file = fs.readFileSync('/data/config.json');   // filesystem access

  return <div>{user.name}</div>;
}
'use client';
// Runs in the browser

import { useState } from 'react';

function LikeButton({ postId }: { postId: string }) {
  // Can do any of these:
  const [liked, setLiked] = useState(false);     // state
  const [count, setCount] = useState(0);

  useEffect(() => { ... }, []);                  // effects
  const handleClick = () => { ... };             // event handlers
  window.localStorage.getItem('theme');          // browser APIs

  return (
    <button onClick={handleClick}>
      {liked ? 'Liked' : 'Like'} ({count})
    </button>
  );
}

A practical summary of the boundaries:

Server Component Client Component
async/await in component body
Database / filesystem access
Server-only environment variables
useState, useEffect, useRef
Event handlers (onClick, onChange)
Browser APIs (localStorage, window)

Composing Server and Client Components

Server and Client Components work together in the same tree. The most common pattern: a Server Component fetches data and passes it as props to a Client Component that handles interactivity.

// ProductPage.tsx — Server Component
// Fetches the data on the server

async function ProductPage({ params }: { params: { id: string } }) {
  const product = await fetch(`https://api.example.com/products/${params.id}`)
    .then((r) => r.json());

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <p>€{product.price}</p>
      {/* Pass data to a Client Component as props */}
      <AddToCartButton productId={product.id} productName={product.name} />
    </div>
  );
}
'use client';
// AddToCartButton.tsx — Client Component
// Handles the interactive part

import { useState } from 'react';

function AddToCartButton({
  productId,
  productName,
}: {
  productId: string;
  productName: string;
}) {
  const [added, setAdded] = useState(false);

  const handleClick = async () => {
    await fetch('/api/cart', {
      method: 'POST',
      body: JSON.stringify({ productId }),
    });
    setAdded(true);
  };

  return (
    <button onClick={handleClick} disabled={added}>
      {added ? `${productName} added!` : `Add ${productName} to cart`}
    </button>
  );
}

The Server Component owns the data. The Client Component owns the interaction. Neither needs to know the implementation details of the other.

The Children Pattern

A Client Component cannot import a Server Component. This is because a Client Component runs in the browser, and Server Components don't exist there. But a Client Component can accept Server Component output as children. This lets you wrap a Server Component in a Client Component without the Client Component needing to know what's inside:

'use client';
// Layout.tsx — Client Component that manages sidebar state

import { useState } from 'react';

function Layout({ children }: { children: React.ReactNode }) {
  const [sidebarOpen, setSidebarOpen] = useState(true);

  return (
    <div className="layout">
      <aside className={sidebarOpen ? 'open' : 'closed'}>
        <button onClick={() => setSidebarOpen(!sidebarOpen)}>
          Toggle
        </button>
      </aside>
      {/* children can be Server Component output */}
      <main>{children}</main>
    </div>
  );
}
// page.tsx — Server Component
// The Layout Client Component wraps Server Component output as children

async function Page() {
  return (
    <Layout>
      {/* This is a Server Component — Layout doesn't need to know that */}
      <ProductList />
    </Layout>
  );
}

Layout handles the sidebar toggle in the browser. ProductList fetches and renders products on the server. Neither component is aware of how the other works, they compose cleanly through the children prop.

Data Fetching in Server Components

The most immediate practical benefit of Server Components is how much simpler data fetching becomes.

Here's the same feature written both ways:

// Before — Client Component with useEffect
'use client';

import { useState, useEffect } from 'react';

function ArticleList() {
  const [articles, setArticles] = useState<Article[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const controller = new AbortController();

    fetch('/api/articles', { signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then((data) => {
        setArticles(data);
        setLoading(false);
      })
      .catch((err) => {
        if (err.name !== 'AbortError') {
          setError(err.message);
          setLoading(false);
        }
      });

    return () => controller.abort();
  }, []);

  if (loading) return <p>Loading articles...</p>;
  if (error) return <p>Error: {error}</p>;

  return (
    <ul>
      {articles.map((article) => (
        <li key={article.id}>{article.title}</li>
      ))}
    </ul>
  );
}
// After — Server Component
// No useState, no useEffect, no loading state, no error state, no cleanup

async function ArticleList() {
  const res = await fetch('/api/articles');

  if (!res.ok) throw new Error(`HTTP ${res.status}`);

  const articles: Article[] = await res.json();

  return (
    <ul>
      {articles.map((article) => (
        <li key={article.id}>{article.title}</li>
      ))}
    </ul>
  );
}

The Server Component version is less than half the length and contains no infrastructure code — only the fetch and the UI. The loading and error states are handled by <Suspense> and error boundaries in the parent, which can wrap multiple components at once rather than each component managing its own.

<aside> ⚠️

Warning

Error handling in Server Components works through error boundaries: a React feature that catches errors thrown during rendering. In Next.js, you add an error.tsx file alongside your page to define what users see when a Server Component throws. This is different from the try/catch inside useEffect you've been writing — the error surfaces during rendering, not inside an effect.

</aside>

Server Functions

Server Functions (previously called Server Actions) are functions that run on the server but can be called from Client Components (including in response to user interactions like form submissions).

You define a Server Function by adding 'use server' at the top of the function or file:

// actions.ts
'use server';

export async function createComment(postId: string, text: string) {
  // This runs on the server — has full access to the database
  await db.comments.create({
    data: { postId, text, createdAt: new Date() },
  });

  // Revalidate the page so the new comment appears
  revalidatePath(`/posts/${postId}`);
}

A Client Component can call this function directly:

'use client';

import { createComment } from './actions';

function CommentForm({ postId }: { postId: string }) {
  const [text, setText] = useState('');

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    await createComment(postId, text);
    setText('');
  };

  return (
    <form onSubmit={handleSubmit}>
      <textarea
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="Write a comment..."
      />
      <button type="submit">Post comment</button>
    </form>
  );
}

The createComment function runs on the server: it has database access, can read secrets, and can trigger cache invalidation. The Client Component calls it as if it were a regular async function. The network request happens automatically in the background.

Server Functions work naturally with useTransition and useOptimistic from the previous chapter, giving you built-in pending states and optimistic updates on top of server-side mutations.

How This Connects to Next.js

The Next.js App Router (introduced in Next.js 13 and stable since Next.js 14) is built entirely around Server Components. Every file in the app/ directory is a Server Component by default. Pages, layouts, and data fetching all use the patterns described in this chapter.

This is why understanding Server Components matters for Next.js development — the App Router isn't just a routing library, it's a full architecture built on the Server/Client Component model.

A few Next.js-specific things to know:

page.tsx is always a Server Component. It can fetch data directly and pass it to Client Components.

layout.tsx is also a Server Component by default, but can be made into a Client Component if it needs state or effects — for things like a collapsible sidebar.

loading.tsx defines the <Suspense> fallback for a route — what users see while Server Components are fetching.

error.tsx defines the error boundary for a route — what users see when a Server Component throws.

app/
├── layout.tsx        ← Server Component (wraps all pages)
├── page.tsx          ← Server Component (home page)
├── loading.tsx       ← Suspense fallback
├── error.tsx         ← Error boundary
└── products/
    ├── page.tsx      ← Server Component (products page)
    └── [id]/
        └── page.tsx  ← Server Component (product detail page)

When to Use Each

The practical rule is simple: start with Server Components, and add 'use client' only when you need something that requires the browser.

Use a Server Component when:

Add 'use client' when:

A well-structured Next.js App Router application has most of its components as Server Components — fetching data, rendering content — with small Client Component islands handling specific interactive pieces: a like button, a search input, a modal, a dropdown.

<aside> 💡

Info

The 'use client' directive doesn't mean "this component only runs in the browser." Client Components are still server-side rendered on the initial page load for performance — they then hydrate in the browser to become interactive. The directive means "this component uses browser APIs and React's client features." The naming is slightly misleading but becomes intuitive with practice.

</aside>

Additional Resources

Videos

Reading