Skip to content
C Codeloom
Next.js

Next.js Server Components Explained

A practical guide to React Server Components in Next.js — what runs where, the use client boundary, how to fetch data inside components, and a first look at server actions.

·9 min read · By Yash Kesharwani
Intermediate 13 min read

What you'll learn

  • What a React Server Component actually is
  • What changes when you add "use client" to a file
  • How the boundary between server and client components works
  • Where to fetch data — and why the answer is almost always the server
  • How async components let you await data inline
  • A first look at server actions for mutations

Prerequisites

React Server Components (RSC) are the most novel idea in the App Router. They change where your component code runs, how data fetching looks, and how much JavaScript ships to the browser. Once the mental model clicks, the rest of Next.js makes more sense.

What a Server Component is

A Server Component is a React component that runs only on the server. It renders to HTML (and a small RSC payload), is sent to the browser, and never executes there. In the App Router, every component is a Server Component by default.

// app/page.tsx — Server Component
export default async function Home() {
  const data = await fetch('https://api.example.com/feed').then((r) =>
    r.json()
  );

  return (
    <main>
      <h1>Latest</h1>
      <ul>
        {data.items.map((i: { id: string; title: string }) => (
          <li key={i.id}>{i.title}</li>
        ))}
      </ul>
    </main>
  );
}

Three things are happening that would not be possible in a plain React app:

  1. The component is async and awaits data inline.
  2. The fetch runs on the server, so secrets in headers, database connections, and private APIs are all fair game.
  3. The browser receives HTML for this component. Zero JavaScript is shipped for Home itself.

That last point is the headline benefit. Server Components do not contribute to your client bundle.

What Server Components cannot do

The price of running on the server is that Server Components have no access to anything browser-specific:

  • No useState, useEffect, useReducer, or any other hook
  • No event handlers like onClick
  • No browser APIs (window, document, localStorage)
  • No CSS-in-JS that depends on runtime React context

If you need any of those, the component must be a Client Component.

The “use client” directive

A Client Component is a normal React component — the kind you have always written. You opt into it with a directive at the top of the file:

// app/components/Counter.tsx
'use client';

import { useState } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);
  return (
    <button onClick={() => setCount(count + 1)}>Count: {count}</button>
  );
}

'use client' is the boundary marker. Everything imported by this file, and every component it renders, becomes part of the client bundle.

A Client Component:

  • Runs on the server during the initial render (to produce HTML) and in the browser (to hydrate and stay interactive)
  • Can use every React hook and every browser API
  • Adds JavaScript to the client bundle

The first thing to internalize: 'use client' does not mean “only runs in the browser.” It means “this component is allowed to run in the browser too.”

The server/client boundary

A Next.js page is a tree. Server Components and Client Components coexist in the tree, but they follow strict rules at the boundary.

Allowed:

// Server Component imports Client Component — fine
import Counter from './Counter'; // 'use client' at top

export default function Page() {
  return (
    <main>
      <h1>My Page</h1>
      <Counter />
    </main>
  );
}

Allowed:

// Server Component passes a Server Component as children to a Client Component
<Card>
  <ServerWidget />
</Card>

Card is a Client Component but ServerWidget still renders on the server. The pattern of passing Server Components through children is the idiomatic way to keep interactive shells (modals, accordions, tabs) cheap.

Not allowed:

// A Client Component cannot import a Server Component directly
'use client';

import ServerWidget from './ServerWidget'; // error

Once you cross the 'use client' line, everything below it is client code. You cannot reach back into the server tree by importing — you can only receive Server Components as children props.

Try it yourself. Create a page that renders a <Card> Client Component containing a <ServerStats> Server Component as children. Make the Card add useState for an “expanded” toggle. Confirm in the Network tab that the JavaScript bundle for the page does not include ServerStats. You just shipped an interactive card whose contents stay free of client JS.

When to use which

A useful rule of thumb:

  • Default to Server Components. Pages, layouts, lists, detail views, marketing chrome, anything that just displays data.
  • Reach for Client Components when you need interactivity. Forms, dropdowns, dark-mode toggles, anything with hooks or event handlers.

A practical pattern: keep the Client Component as small as possible. A search page might be a Server Component that fetches the results, with a tiny Client Component for the input box.

// app/search/page.tsx — server
import SearchInput from './SearchInput';

export default async function SearchPage({
  searchParams,
}: {
  searchParams: Promise<{ q?: string }>;
}) {
  const { q } = await searchParams;
  const results = q
    ? await fetch(`https://api.example.com/search?q=${q}`).then((r) => r.json())
    : [];

  return (
    <main>
      <SearchInput defaultValue={q} />
      <ul>
        {results.map((r: { id: string; title: string }) => (
          <li key={r.id}>{r.title}</li>
        ))}
      </ul>
    </main>
  );
}
// app/search/SearchInput.tsx — client
'use client';

import { useRouter } from 'next/navigation';
import { useState } from 'react';

export default function SearchInput({ defaultValue }: { defaultValue?: string }) {
  const [value, setValue] = useState(defaultValue ?? '');
  const router = useRouter();

  return (
    <input
      value={value}
      onChange={(e) => setValue(e.target.value)}
      onKeyDown={(e) => {
        if (e.key === 'Enter') router.push(`/search?q=${value}`);
      }}
    />
  );
}

The page does the data work; the input handles keystrokes. Each piece runs where it makes sense.

Async Server Components

The single most useful feature of RSC is that components can be async:

async function UserCard({ id }: { id: string }) {
  const user = await fetch(`https://api.example.com/users/${id}`).then((r) =>
    r.json()
  );
  return <p>{user.name}</p>;
}

No useEffect. No useState for loading or errors. No race conditions on unmount. The data is fetched on the server before the HTML is sent.

You can await databases directly:

import { db } from '@/lib/db';

export default async function PostList() {
  const posts = await db.post.findMany();
  return (
    <ul>
      {posts.map((p) => (
        <li key={p.id}>{p.title}</li>
      ))}
    </ul>
  );
}

That db.post.findMany() runs on the server. The connection string never reaches the browser. The Prisma client never enters the client bundle.

This is the most important shift compared to a plain React SPA. Most of the patterns you learned around useEffect for data fetching are no longer needed.

Streaming and Suspense

Async Server Components plug into React Suspense automatically. A loading.tsx file (covered in the previous post) becomes the fallback for the whole route. You can also wrap part of a page in <Suspense> to stream just that section:

import { Suspense } from 'react';

export default function Page() {
  return (
    <main>
      <h1>Dashboard</h1>
      <Suspense fallback={<p>Loading stats...</p>}>
        <Stats />
      </Suspense>
      <Suspense fallback={<p>Loading recent activity...</p>}>
        <Activity />
      </Suspense>
    </main>
  );
}

The header arrives instantly. Each section streams in as its data resolves. The user sees progress instead of a blank screen.

Server Actions

The other half of the server-first story is server actions — functions that you define on the server and call from the client.

A server action is a function marked with 'use server':

// app/posts/actions.ts
'use server';

import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string;
  await db.post.create({ data: { title } });
  revalidatePath('/posts');
}

You can use that function as a form action, directly:

// app/posts/new/page.tsx
import { createPost } from '../actions';

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

When the user submits, Next sends the form data to the server, runs createPost, and re-renders the affected paths. No useState, no fetch, no /api/posts route handler, no JSON serialization by hand. The mutation lives in code next to the data, not in a separate API project.

We treat server actions in depth in the next post on data fetching. The headline for now: they are the modern way to do mutations in Next.js.

Try it yourself. Build a tiny in-memory todo list. Page is a Server Component that reads from a module-level array. A createTodo server action pushes a new item and calls revalidatePath('/'). Submit the form a few times and watch the list update without a single line of useState or useEffect. That is the entire server-first mutation flow.

Common confusions

A few things that trip people up.

”Why does my Client Component render on the server too?”

It does. Client Components are server-rendered for the initial HTML so the page is not a blank screen. They hydrate in the browser afterwards. 'use client' is about where the component is allowed to run, not where it exclusively runs.

”Why is my context not working in a Server Component?”

React context relies on useContext, which is a hook. Hooks only work in Client Components. Wrap the top of the tree that needs context in a Client Component provider, and put it inside the root layout.

”Can I use a Server Component inside a Client Component?”

Only as children. You cannot import a Server Component from a Client Component file. Passing it through children is the pattern.

”Where does the secret go?”

Anywhere a Server Component runs. The component, route handler, server action, and middleware all run on the server. process.env.API_KEY is safe in all of them. It is only NEXT_PUBLIC_* variables that get inlined into the client bundle.

Recap

You now know:

  • Every component is a Server Component by default — it runs on the server and ships no client JS
  • 'use client' marks a file as a Client Component, which can use hooks and event handlers
  • Server Components can be async and await data, databases, and private APIs directly
  • The server/client boundary is one-way: server can render client, client cannot import server (but can receive it as children)
  • <Suspense> and loading.tsx stream parts of a page as they resolve
  • Server actions marked with 'use server' are the modern way to do mutations from forms and event handlers

Next steps

The next post goes deeper into data — Next’s fetch cache, static vs dynamic rendering, revalidate, generateStaticParams, and how server actions update the cache after a mutation.

→ Next: Data Fetching in Next.js: fetch, cache, and revalidate

Questions or feedback? Email codeloomdevv@gmail.com.