Skip to content

Server Components in Practice

Aman Kumar Singh8 min read
Part 16 of 50From the React & Next.js Complete Guide series
Server Components in Practice — article by Aman Kumar Singh

In The Next.js App Router Explained, we walked through how the App Router organizes routes, layouts, and loading states around the filesystem. What we glossed over is the default rendering model underneath it: every component in the app/ directory is a Server Component unless you say otherwise. That default is a bigger shift than most teams realize. Plenty adopt it without ever consciously deciding to.

This is article five of the React & Next.js Complete Guide, and it's the one where the mental model has to click. Server Components aren't "SSR but newer." They're a different execution boundary, with different rules about what code runs where, and getting that boundary wrong is how a client bundle ends up with your database driver in it, or a component mysteriously can't hold state.

I'll treat you as someone comfortable with React already: props, hooks, effects, all of it. The question this article answers is which side of the network a given component's code belongs on, and what that decision costs.

What a Server Component actually is

A Server Component renders on the server, once, and never ships its own JavaScript to the browser. It can be async, it can read from a database or call an internal service directly, and its output is a serialized description of UI that React streams down and reconciles into the DOM. No hydration happens for it, because there's no client-side instance of it to hydrate.

// app/dashboard/page.tsx
import { db } from "@/lib/db";

async function getRecentOrders(orgId: string) {
  return db.order.findMany({
    where: { orgId },
    orderBy: { createdAt: "desc" },
    take: 20,
  });
}

export default async function DashboardPage({
  searchParams,
}: {
  searchParams: { orgId: string };
}) {
  const orders = await getRecentOrders(searchParams.orgId);

  return (
    <section>
      <h1>Recent orders</h1>
      <ul>
        {orders.map((order) => (
          <li key={order.id}>
            {order.reference} — {order.status}
          </li>
        ))}
      </ul>
    </section>
  );
}

Notice what's missing compared to the equivalent client-side fetch: no useEffect, no loading state managed in React, no API route to proxy the query through. The component just awaits the data it needs and renders. That's the entire pitch for Server Components: they collapse "fetch, then render" into a single async function, one that talks to your data layer directly instead of going through a public API you'd otherwise have to build just so the browser can call it.

The tradeoff is that this component genuinely never runs in the browser. It can't use useState, useEffect, onClick, or any browser API, because there's no client runtime for it. Try to add one and Next.js will tell you at build time. That restriction is the whole point: a component that only reads data and renders markup doesn't need that machinery, and every byte of it is gone from the browser bundle entirely.

The client boundary: where "use client" starts

"use client" at the top of a file doesn't mean "this runs on the client instead of the server." It means this component and everything it imports crosses into client-rendered territory, and its JavaScript becomes part of the browser bundle. Next.js still renders client components on the server for the initial HTML. Then it hydrates them so they can hold state and respond to events.

"use client";

import { useState } from "react";

export function OrderStatusFilter({
  onChange,
}: {
  onChange: (status: string) => void;
}) {
  const [status, setStatus] = useState("all");

  return (
    <select
      value={status}
      onChange={(event) => {
        setStatus(event.target.value);
        onChange(event.target.value);
      }}
    >
      <option value="all">All</option>
      <option value="pending">Pending</option>
      <option value="shipped">Shipped</option>
    </select>
  );
}

The rule that trips people up is that the boundary follows imports. A directive in one file pulls in everything that file imports too. A Server Component importing a client component is fine: the client component becomes an island of interactivity inside a server-rendered tree. A client component importing something that assumes server-only APIs (a database client, a filesystem read, a secret-bearing SDK) is the problem. That code now has to run in the browser and usually can't, or it drags server dependencies into the client bundle whether or not it's actually called.

The fix is to keep data access in Server Components and pass only the data a client component needs down as props, never the fetching logic itself.

// app/dashboard/page.tsx (Server Component)
import { OrderStatusFilter } from "./order-status-filter";

export default async function DashboardPage() {
  const orders = await getRecentOrders();
  return (
    <>
      <OrderStatusFilter onChange={() => {}} />
      <OrderList orders={orders} />
    </>
  );
}

onChange handlers that need to trigger a new server fetch usually go through a route transition (updating a search param via useRouter) or a Server Action, since functions can't cross the serialization boundary. That's worth internalizing early: props passed from a Server Component to a Client Component have to be serializable. A Date needs to become an ISO string; a callback needs to become a Server Action or a handler defined locally in the client component.

Composing server and client components without leaking bundle size

The instinct that causes the most damage is reaching for "use client" at the top of a whole page because one small piece of it needs interactivity. Once you do that, every component that page imports, including ones that only render static markup, gets pulled into the client bundle and loses the "never ships JS" benefit entirely.

The better pattern is to push the client boundary as far down the tree as possible, wrapping only the interactive leaf, and let everything else stay a Server Component.

// app/dashboard/order-list.tsx (Server Component, no directive)
import { OrderRow } from "./order-row";

export function OrderList({ orders }: { orders: Order[] }) {
  return (
    <ul>
      {orders.map((order) => (
        <OrderRow key={order.id} order={order} />
      ))}
    </ul>
  );
}

// app/dashboard/order-row.tsx
"use client";

export function OrderRow({ order }: { order: Order }) {
  const [expanded, setExpanded] = useState(false);
  return (
    <li onClick={() => setExpanded((value) => !value)}>
      {order.reference}
      {expanded && <span> — {order.status}</span>}
    </li>
  );
}

OrderList stays a Server Component, OrderRow is the client island. This decision is architectural: it determines which parts of the dashboard ship JavaScript and which parts are pure server-rendered markup. On a page with a hundred rows and one that needs a dropdown, "the whole page is client" and "one row is client" differ by roughly a hundred hydrated instances.

Streaming and Suspense: rendering as data arrives

Server Components stream. Wrapping a slow data-dependent section in <Suspense> lets the rest of the page render and reach the browser immediately. The slow part fills in once its data resolves, instead of the whole page waiting on the slowest query.

import { Suspense } from "react";

export default function DashboardPage() {
  return (
    <>
      <DashboardHeader />
      <Suspense fallback={<OrdersSkeleton />}>
        <RecentOrders />
      </Suspense>
      <Suspense fallback={<StatsSkeleton />}>
        <UsageStats />
      </Suspense>
    </>
  );
}

RecentOrders and UsageStats can be independent async Server Components, each awaiting its own query. Because they sit in separate Suspense boundaries, a slow analytics query doesn't block the order list from appearing, and vice versa. That's genuinely different from a traditional server-rendered page, where the server finishes the entire render before sending anything.

Watch for sequential awaits inside a single component masquerading as parallel work: await getOrders() followed by await getStats() in the same function runs those queries one after another. If they're independent, kick them off together with Promise.all, or split them into separate Server Components with their own Suspense boundaries so React streams each independently.

// Sequential: getStats waits for getOrders to finish first
const orders = await getOrders();
const stats = await getStats();

// Parallel: both requests are in flight at the same time
const [orders, stats] = await Promise.all([getOrders(), getStats()]);

Production pitfalls I've seen bite teams

A few mistakes show up repeatedly once Server Components move from a demo into a real app.

The first is treating every fetched value as safe to hand to a client component. Server Components can read secrets, internal tokens, or full database rows, and it's easy to pass an entire object down as a prop without noticing it includes fields the client never needed. Shape the data before it crosses the boundary: return a DTO with exactly the fields the client component uses, not the raw row.

The second is caching confusion. fetch calls inside Server Components are cached and deduplicated by Next.js by default, which is convenient until it silently serves stale data on a mutation-heavy dashboard. Be explicit about cache: "no-store" or a revalidate value for anything that needs fresh data on every request.

The third is forgetting that Server Components re-render on every navigation to that route, so any expensive computation inside one runs again on every request unless it's cached at the data layer (Redis, a materialized view, or the framework's own data cache). A Server Component isn't free just because it ships no JavaScript; it still costs CPU and I/O on the server per request.

The fourth is importing a third-party library that assumes a browser environment (window, localStorage, internal useEffect calls) into a Server Component. It fails at build or render time, and the fix is almost always to isolate that library inside its own client component and pass down only the data it needs.

Key takeaways

  • Server Components run once on the server and ship no JavaScript to the browser; they can be `async` and read from a database or internal service directly, replacing the fetch-then-render dance.
  • `"use client"` marks a boundary that pulls a file and its imports into the client bundle; push that boundary as far down the tree as possible so only genuinely interactive pieces pay the hydration cost.
  • Props passed from a Server Component to a Client Component must be serializable; convert dates to strings and replace callback props with Server Actions or client-defined handlers.
  • Wrap independent, slow data-dependent sections in separate `Suspense` boundaries so the page can stream progressively instead of waiting on the slowest query.
  • Check for accidental sequential awaits; independent server-side data fetches should run with `Promise.all`, not one after another.
  • Server Components still cost CPU and I/O per request; caching and data shaping (DTOs instead of raw rows) matter just as much as they did before this model existed.

Frequently asked questions

Are Server Components the same thing as server-side rendering?

No. SSR renders a client-capable component tree to HTML on the server and then hydrates the whole thing in the browser. Server Components never ship their own JavaScript or hydrate at all; they're a separate execution model that Next.js combines with SSR and streaming for the parts of the tree that do need to run client-side.

Can a Server Component use useState or useEffect?

No. Both require a client runtime to manage state and run effects, and a Server Component never executes in the browser. If a piece of UI needs local state or lifecycle behavior, extract it into its own file with `"use client"` at the top and pass it only the data it needs as props.

How do I fetch data in a Server Component without introducing a waterfall?

Identify which fetches are actually independent of each other and start them together with `Promise.all`, or split them into separate `async` Server Components each wrapped in its own `Suspense` boundary so React can stream them as they resolve rather than serializing them.

Why did importing a library into my page suddenly break the build?

The library likely assumes a browser environment or relies on client-only React hooks internally. Move it into a file marked `"use client"` so it's isolated to the client bundle, and pass it only the serialized data it needs from the Server Component that fetched it.

Does using Server Components mean I don't need an API layer anymore?

Not entirely. Server Components remove the need for an internal API route just to feed your own pages, since they can call your data layer directly. You'll still want a real API (or Server Actions) for anything a client component needs to trigger after the initial render, like a form submission or a mutation.

What happens to props I pass from a Server Component if they're not serializable?

Next.js will throw a build or runtime error. Functions, class instances, and non-plain objects don't survive the serialization boundary between server and client. Convert dates to ISO strings, plain-object your data, and replace function props with Server Actions or handlers defined locally inside the client component.

Related articles

Core Web Vitals for React Apps — Aman Kumar Singh
Image and Asset Optimization — Aman Kumar Singh
Building a Design System — Aman Kumar Singh

Explore more on

Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.