AnyLearn
All lessons
Programmingintermediate

Server vs Client Components in Next.js

The RSC mental model: server-by-default, the 'use client' boundary, what can cross it, Server Actions with 'use server', streaming with Suspense, and a Postgres-to-Client end-to-end example.

Not signed in β€” your progress and quiz score won't be saved.
Lesson progress1 / 10

Server-by-default

In the Next.js 15 App Router, every component is a React Server Component (RSC) by default. That means it runs on the server, never ships to the browser as JavaScript, and can await server resources directly β€” databases, files, secrets.

// app/posts/page.tsx (Server Component)
import { db } from '@/lib/db';

export default async function PostsPage() {
  const posts = await db.posts.list();   // direct DB call, server-only
  return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}

The component above sends zero JS to the client. The browser receives HTML with the data already rendered. No useEffect, no loading spinner, no fetch wrapper. This is the biggest reset in React patterns since hooks.

Full lesson text

All 10 steps on one page β€” for reading, reference, and search.

Show

1. Server-by-default

In the Next.js 15 App Router, every component is a React Server Component (RSC) by default. That means it runs on the server, never ships to the browser as JavaScript, and can await server resources directly β€” databases, files, secrets.

// app/posts/page.tsx (Server Component)
import { db } from '@/lib/db';

export default async function PostsPage() {
  const posts = await db.posts.list();   // direct DB call, server-only
  return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}

The component above sends zero JS to the client. The browser receives HTML with the data already rendered. No useEffect, no loading spinner, no fetch wrapper. This is the biggest reset in React patterns since hooks.

2. 'use client': opting into the browser

When you need state, effects, browser APIs, or event handlers, you opt that component into being a Client Component with a directive at the top of the file:

'use client';
import { useState } from 'react';

export function Counter() {
  const [n, setN] = useState(0);
  return <button onClick={() => setN(n + 1)}>{n}</button>;
}

The directive marks a boundary. Everything imported transitively from this file is now part of the client bundle. A Server Component can render Client Components (it just sends the component tree as data), but a Client Component cannot render a Server Component as a child β€” it can only accept one as a children prop.

3. The server / client boundary

Server Components render on the server; Client Components hydrate in the browser. Props cross the boundary as serialized data.

flowchart LR
  A["Server Component (page.tsx)"] --> B["fetch from Postgres"]
  A --> C["Server Component (PostList)"]
  A --> D["Client Component (LikeButton)"]
  D -->|hydrates in browser| E["DOM: useState, onClick"]
  C -->|serialized JSON props| D

4. What can cross the boundary

Props from a Server Component to a Client Component must be serializable. The set is roughly: primitives, arrays, plain objects, Dates, Maps, Sets, Promises, JSX elements (yes), and other Server Components passed as children.

The set explicitly does NOT include: functions, class instances, DB connection objects, anything non-serializable.

// βœ… allowed: pre-fetched data
<LikeButton postId={post.id} initialLikes={post.likes} />

// ❌ disallowed: a function defined in the server component
<LikeButton onLike={() => db.likes.add(post.id)} />

The right way to ship behavior across the boundary is Server Actions β€” covered next.

5. Server Actions with 'use server'

A Server Action is a function annotated with 'use server' that the framework exposes to the client as a callable. Under the hood it's an RPC, but it looks like a regular function import.

// app/actions/likes.ts
'use server';
import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';

export async function likePost(postId: string) {
  await db.likes.create({ postId, userId: getCurrentUserId() });
  revalidatePath(`/posts/${postId}`);   // refresh server-rendered HTML
}

Any client component can import and call it as if it ran locally:

'use client';
import { likePost } from '@/app/actions/likes';
<button onClick={() => likePost(id)}>Like</button>

Next.js handles the wire format, CSRF protection, and serialization. From the browser's view: a network round-trip; from your code's view: a function call.

6. Forms with Server Actions

Server Actions slot directly into the <form action={...}> prop in React 19. No event.preventDefault, no fetch, no useState for form fields:

// server action
'use server';
export async function createPost(formData: FormData) {
  const title = String(formData.get('title'));
  await db.posts.create({ title });
  revalidatePath('/posts');
}
// usage in a page (can be a Server Component!)
import { createPost } from './actions';

export default function NewPost() {
  return (
    <form action={createPost}>
      <input name="title" />
      <button type="submit">Create</button>
    </form>
  );
}

For pending states and optimistic updates, sprinkle in useActionState and useOptimistic on the client. But the baseline β€” form posts that mutate the DB β€” works without a single line of client JS.

7. Streaming and Suspense boundaries

Slow data shouldn't block the rest of the page. Wrap the slow Server Component in <Suspense> and Next streams the placeholder first, then the data.

import { Suspense } from 'react';

export default function Dashboard() {
  return (
    <>
      <Header />                          {/* renders instantly */}
      <Suspense fallback={<Skeleton />}>
        <SlowRecommendations />           {/* awaits 2s, streams in */}
      </Suspense>
    </>
  );
}

The browser sees <Header /> and the skeleton immediately. The skeleton's HTML carries a placeholder marker; when the server finishes SlowRecommendations, Next streams the real HTML and a tiny <script> that swaps it in. From a user's perspective: faster first paint and a single page, not a spinner-then-page.

8. When to use which

Rule of thumb:

You want…Component type
To fetch data from DB / file / paid APIServer
Markdown rendering, big formatting libsServer
onClick, onChange, useState, useEffectClient
Browser APIs (localStorage, window, IntersectionObserver)Client
Animation libraries (framer-motion)Client
Static-y wrappers around a small interactive childServer, with a 'use client' leaf

The pattern is: push 'use client' as far down the tree as possible. A page that ends up 95% server-rendered with a small client island for the comment form is the ideal shape. You ship less JS, hydrate less HTML, and the page is fast on cheap phones.

9. End-to-end: Postgres β†’ Server β†’ Client

Here's the canonical pattern: server reads data, hands typed data to a client component for interactivity.

// app/posts/page.tsx β€” Server Component
import { db } from '@/lib/db';
import { PostCard } from './post-card';

export default async function PostsPage() {
  const posts = await db.post.findMany({
    select: { id: true, title: true, likes: true },
  });
  return (
    <ul>
      {posts.map(p => <PostCard key={p.id} post={p} />)}
    </ul>
  );
}
// app/posts/post-card.tsx β€” Client Component
'use client';
import { likePost } from '@/app/actions/likes';

export function PostCard({ post }: { post: { id: string; title: string; likes: number } }) {
  return (
    <li>
      {post.title}
      <button onClick={() => likePost(post.id)}>{post.likes} β™₯</button>
    </li>
  );
}

Server fetches and types the data. Client owns the interaction. The Server Action closes the loop with a real DB write.

10. Common gotchas

Five mistakes that bite everyone in the first month:

  1. Adding 'use client' to the root layout β€” turns the entire app into a client bundle. Almost never what you want.
  2. Importing server-only code into a client component β€” you'll get a build error. The fix is to call it via a Server Action instead.
  3. Passing event handlers from Server β†’ Client β€” handlers are functions; functions don't serialize. Use Server Actions.
  4. Forgetting revalidatePath / revalidateTag β€” your mutation succeeds but the listing page keeps showing stale data because it's been cached.
  5. Reaching for useEffect to fetch data on a page β€” almost always the wrong move now. Fetch in the Server Component above, pass data down as props.

Get these right and the App Router stops surprising you.

Check your understanding

The lesson ends with a 5-question quiz. Take it in the player above to see your score.

  1. Which is the default in the Next.js 15 App Router?
    • Server Components β€” opt into the client with 'use client'.
    • Client Components β€” opt into the server with 'use server'.
    • Static HTML with no React runtime.
    • Hybrid β€” Next chooses per render based on heuristics.
  2. A Server Component tries to pass `onLike={() => db.likes.add(id)}` as a prop to a Client Component. What happens?
    • Build/runtime error: functions defined in Server Components aren't serializable across the boundary.
    • It works; Next.js wraps the function in a Server Action automatically.
    • It works but only in development mode.
    • The Client Component receives an empty stub function.
  3. After a Server Action mutates the DB, why does the visible list often still show stale data?
    • The route's HTML/data is cached; call revalidatePath or revalidateTag to invalidate it.
    • Server Components never re-render; you must hard-reload.
    • The browser cached the React tree in service worker storage.
    • useState in the page snapshot the old value.
  4. Which is the RIGHT place to put 'use client'?
    • As deep in the tree as possible, on the smallest leaf that needs interactivity.
    • On the root layout, so every component can use hooks if it wants.
    • On the Server Action file, to make it callable from the browser.
    • On every file, then remove it where it isn't needed.
  5. You want a header and search bar to paint immediately while a slow recommendations module loads. Best mechanism?
    • Wrap <Recommendations /> in <Suspense fallback={...}> inside the Server Component.
    • Move recommendations into a Client Component that uses useEffect.
    • Set the recommendations component as 'use client' with await.
    • Disable streaming in next.config.js so all content loads atomically.

Related lessons