ESC

Type to search the knowledge base.

Server Components in Next.js

RSC mental model in the App Router — server vs client boundaries, data fetching, serialization rules, and composition patterns that stay maintainable.

intermediate5 min read
  • nextjs
  • server-components
  • rsc
  • react

In the Next.js App Router, components are Server Components by default. They render on the server (at build or request time), can access backend resources directly, and ship minimal or zero interactive JS for their own logic. Client Components opt in with "use client" when you need state, effects, or browser-only APIs.

This page goes deep on boundaries and composition. For routing file conventions, see App Router overview.

Docs: Next.js Server Components, Client Components, React RSC.

What “server” means here

A Server Component:

  1. Runs in a Node (or edge) server environment during render — not in the browser.
  2. Can be async and await data.
  3. Can import server-only modules (DB clients, secret env vars) if marked/structured correctly.
  4. Does not use useState, useEffect, DOM APIs, or browser event handlers directly.
  5. Serializes its output (and props to client children) across the wire as the RSC payload + HTML.

They are not “SSR of a classic client tree” only — the component model itself is split.

Server vs Client decision table

Need Prefer
Fetch data, read DB, use secrets Server Component
Reduce JS shipped for static UI Server Component
useState / useReducer Client Component
useEffect, subscriptions, browser APIs Client Component
onClick / onChange handlers Client Component
Context that changes often on the client Client Component
Custom hooks that use the above Client Component module

Rule: make the leaf interactive, keep parents as Server Components when possible.

// app/dashboard/page.tsx — Server Component
import { db } from '@/lib/db';
import { FilterBar } from './filter-bar'; // client
import { Chart } from './chart'; // server-friendly presentational

export default async function DashboardPage() {
  const stats = await db.stats.overview();
  return (
    <main>
      <FilterBar />
      <Chart data={stats} />
    </main>
  );
}
// filter-bar.tsx
'use client';

import { useState } from 'react';

export function FilterBar() {
  const [q, setQ] = useState('');
  return (
    <input
      value={q}
      onChange={(e) => setQ(e.target.value)}
      aria-label="Filter"
    />
  );
}

The client boundary

"use client" marks the file as the entry of a client bundle. That module and its imports become client-side, with exceptions for pure type imports and certain patterns.

Implications:

  1. Put "use client" only in files that need it — not in page.tsx if a child can own interactivity.
  2. A Server Component can render a Client Component as a child.
  3. A Client Component cannot import a Server Component module directly.
  4. Pass Server Components as children or props from a server parent into a client wrapper (composition pattern):
// client-shell.tsx
'use client';

export function ClientShell({ children }: { children: React.ReactNode }) {
  return <div className="shell">{children}</div>;
}

// page.tsx — server
import { ClientShell } from './client-shell';
import { ServerList } from './server-list';

export default function Page() {
  return (
    <ClientShell>
      <ServerList /> {/* still a Server Component */}
    </ClientShell>
  );
}

This “children as server slots” pattern is how you keep a client chrome without forcing the whole tree client-side.

Data fetching patterns

In Server Components

export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const res = await fetch(`https://api.example.com/products/${id}`, {
    next: { revalidate: 60 },
  });
  const product = await res.json();
  return <ProductView product={product} />;
}

Colocate fetch with the component that needs the data. Use Suspense boundaries + loading.tsx so slow subtrees don’t block the whole page shell — good for LCP and perceived CRP.

Not “getServerSideProps”

There is no getServerSideProps in app/. Async components + caching options replace that mental model. Dynamic functions (cookies(), headers()) opt routes into dynamic rendering.

What can cross the boundary (props)

Props from Server → Client must be JSON-serializable (plus a few supported types like Date in some versions — stick to plain data).

// Bad: passing a function or class instance from server to client
<ClientWidget onSave={async () => db.save()} />

// Good: Server Action reference (supported pattern) or client fetch to a route

Server Actions ("use server") give you callable server functions from client forms/buttons — still validate auth on the server.

// actions.ts
'use server';

export async function updateName(formData: FormData) {
  const name = String(formData.get('name') ?? '');
  // authz + validation + mutation
}
// form.tsx
'use client';

import { updateName } from './actions';

export function NameForm() {
  return (
    <form action={updateName}>
      <input name="name" />
      <button type="submit">Save</button>
    </form>
  );
}

Never trust client input; treat actions like public POST endpoints.

Bundle and performance effects

Server Components aim to:

  • Cut JS parse/hydrate costs → helps INP under load of heavy pages.
  • Keep large dependency graphs (markdown parsers, ORM) on the server.
  • Stream HTML so shells paint earlier (Core Web Vitals).

Pitfalls:

  1. Giant client islands reintroduced by one high "use client" boundary.
  2. Waterfalls of sequential awaits in one component — fetch in parallel (Promise.all) when independent.
  3. Serializing huge props to client children — pass ids and fetch on server instead.
  4. Importing server-only code into a client file (build errors or leaks).

Use server-only package to hard-fail accidental client imports of sensitive modules.

Auth and secrets

import 'server-only';
import { db } from './db';

export async function getPrivateUser(id: string) {
  return db.user.findPrivate(id);
}

Env vars without NEXT_PUBLIC_ stay server-side. Don’t pass secrets as props to client components. Cookie/session reads belong in server code or middleware with a clear trust model.

Testing your mental model

Ask for every file:

  1. Does this need hooks or browser APIs?
  2. If yes, is it the smallest leaf that could own them?
  3. Does any prop I pass fail serialization?
  4. Am I duplicating data fetching on client after server already had it?

If you answer “I put use client on the page so it works,” push the boundary down.

Interview angle

Define RSC vs Client Component in Next. Explain default server, "use client" leaves, children composition, serializable props, and async server data fetching. Mention Server Actions as POST-like endpoints needing authz. Tie to JS weight and CWV.

Further reading

Related guides