Server Components

Server Components are the default in App Router. They run on the server, never ship JavaScript to the browser, and can use async/await directly for data fetching. No useEffect, no useState, no client-side data loading boilerplate.

The Default is Server

Every component in app/ is a Server Component unless you add "use client" at the top. This is React Server Components (RSC), not a Next.js invention, but Next.js was the first framework to ship it as the default.

// app/dashboard/page.tsx - this is a Server Component
// No "use client" directive = runs on the server

export default async function Dashboard() {
  const stats = await db.query("SELECT * FROM stats");

  return (
    <div>
      <h1>Dashboard</h1>
      <p>Total users: {stats.totalUsers}</p>
      <p>Revenue: ${stats.revenue}</p>
    </div>
  );
}

This component:

  • Runs on the server only
  • Ships zero JavaScript to the client
  • Can directly access databases, file systems, env vars
  • Can use async/await at the component level

Data Fetching

Forget useEffect + useState + loading states. In Server Components, you just await:

// Server Component - direct async data fetching
async function UserProfile({ userId }: { userId: string }) {
  const user = await db.user.findUnique({ where: { id: userId } });
  const posts = await db.post.findMany({ where: { authorId: userId } });

  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.bio}</p>
      <PostList posts={posts} />
    </div>
  );
}

Compare with the old client-side pattern:

// Client Component - the old way (still needed for interactive data)
"use client";
import { useEffect, useState } from "react";

function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then((res) => res.json())
      .then((data) => { setUser(data); setLoading(false); });
  }, [userId]);

  if (loading) return <Spinner />;
  return <div>{user.name}</div>;
}

Tip: Server Components eliminate waterfalls. Multiple await calls in a component run on the server (fast, close to the data source), and only the final HTML streams to the client.

”use client” Boundary

Add "use client" when a component needs browser APIs, state, or event handlers:

"use client";

import { useState } from "react";

export function LikeButton({ postId }: { postId: string }) {
  const [liked, setLiked] = useState(false);

  return (
    <button onClick={() => setLiked(!liked)}>
      {liked ? "Liked" : "Like"}
    </button>
  );
}

The Boundary Rule

"use client" creates a boundary. Everything imported by a Client Component also becomes a Client Component, even without the directive.

Server Component (page.tsx)
  -> Server Component (PostContent)     -- stays on server
  -> Client Component (LikeButton)      -- "use client"
      -> utility function (helpers.ts)  -- becomes client-side too

Gotcha: The most common mistake is putting "use client" at the top of a page or layout. This forces everything inside it to be client-side, killing all Server Component benefits. Push "use client" to the smallest leaf components.

Composition Pattern

The key pattern: Server Components can pass data to Client Components as props, including other Server Components as children.

// app/post/[id]/page.tsx (Server Component)
import { LikeButton } from "./like-button"; // Client Component
import { CommentList } from "./comment-list"; // Server Component

export default async function PostPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const post = await getPost(id);
  const comments = await getComments(id);

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
      <LikeButton postId={id} initialCount={post.likes} />
      <CommentList comments={comments} />
    </article>
  );
}
// like-button.tsx (Client Component - only this ships JS)
"use client";

import { useState } from "react";

export function LikeButton({ postId, initialCount }: { postId: string; initialCount: number }) {
  const [count, setCount] = useState(initialCount);

  async function handleLike() {
    setCount((c) => c + 1);
    await fetch(`/api/posts/${postId}/like`, { method: "POST" });
  }

  return <button onClick={handleLike}>Likes: {count}</button>;
}

What You Can and Cannot Do

Server ComponentClient Component
async/await in componentYesNo
useState, useEffectNoYes
Event handlers (onClick)NoYes
Access database/filesystemYesNo
Read env vars (non-NEXT_PUBLIC_)YesNo
Import Server ComponentsYesNo (but can receive as children)
Import Client ComponentsYesYes

Serialization Boundary

Props passed from Server to Client Components must be serializable: strings, numbers, booleans, arrays, plain objects, Dates, Maps, Sets, and special React types like JSX (as children). You cannot pass functions, class instances, or Symbols.

// This works
<ClientComponent data={{ name: "Alice", count: 42 }} />

// This does NOT work - functions are not serializable
<ClientComponent onSubmit={async (data) => await save(data)} />

Tip: If you need to pass a function-like behavior from server to client, use a Server Action instead. Server Actions are serializable references to server-side functions.

Third-Party Libraries

Most React UI libraries (date pickers, modals, charts) need useState or useEffect. Wrap them in a Client Component boundary:

// components/chart-wrapper.tsx
"use client";

import { BarChart } from "some-chart-library";

export function ChartWrapper({ data }: { data: ChartData[] }) {
  return <BarChart data={data} />;
}
// app/analytics/page.tsx (Server Component)
import { ChartWrapper } from "@/components/chart-wrapper";

export default async function Analytics() {
  const data = await getAnalyticsData(); // fetch on server

  return <ChartWrapper data={data} />; // pass to client for rendering
}

Next: App Router | Caching