Home / Articles / Where to Draw the Server-Client Line in a Next.js App Router Page

This article is published in English.

Where to Draw the Server-Client Line in a Next.js App Router Page

A practical mental model for React Server Components: what runs where, how a blog post page splits into server and client parts, and the import rules to follow.

1321 words

React Server Components often feel slippery even after you have read the documentation, mostly because they ask you to drop an assumption every React developer has held for years: that components run in the browser. Once that assumption goes, the rest follows fairly naturally. This article builds the mental model around a single blog post page, showing which parts belong on the server, which need the client, and the handful of rules that keep the boundary between them clean.

For a deeper look at the rendering pipeline itself, see the architecture behind zero-bundle rendering with Server Components.

From "everything hydrates" to "only what needs to"

Before Server Components, every React component eventually ran in the browser. Even with server-side rendering, the JavaScript for the whole tree was shipped to the client and hydrated so the application could become interactive. That is wasteful for components that only fetch data and pass it down as props: their code is downloaded and run without ever reacting to the user.

A Server Component changes that by running only on the server. Its code is never sent to the browser and it is never hydrated, so it adds no JavaScript to the client bundle. What reaches the browser is its rendered output, which React serialises into a compact payload alongside the HTML.

The simplest way to hold the two kinds in your head:

  • Client Component: runs in the browser (after being pre-rendered on the server), can hold state, and responds to events.
  • Server Component: runs on the server, can read data directly from databases or files, and sends back only rendered output.

Why the distinction pays off

Take a typical blog post page. The title and body come from a database, look the same for every visitor and ignore clicks. Traditionally, the code that renders them, plus any markdown or formatting libraries, still travels to the browser. With Server Components, all of that stays on the server. The bundle shrinks and pages tend to load faster, especially on slower devices.

Teams moving to the App Router, which is built around Server Components, have reported better Core Web Vitals, notably LCP, in some scenarios. Treat these as context-dependent: the gain depends on how much client JavaScript your pages shed, so measure your own routes.

A blog post page, split in two

In a Next.js App Router project, a page file is a Server Component unless it says otherwise. The page below is an async function that awaits the post straight from the data layer, handles the not-found case, renders the static content, and then drops in a single interactive child for comments. It is written against the Next.js 14 API:

// app/posts/[slug]/page.tsx
// This is a Server Component by default — no "use client" needed

import { getPostBySlug } from "@/lib/db"
import { PostContent } from "@/components/PostContent"
import { CommentSection } from "@/components/CommentSection"

type Props = {
  params: { slug: string }
}

export default async function PostPage({ params }: Props) {
  // Direct DB call — no useEffect, no API route, no loading state
  const post = await getPostBySlug(params.slug)

  if (!post) {
    return <div>Post not found.</div>
  }

  return (
    <article className="max-w-2xl mx-auto py-12 px-4">
      <h1 className="text-3xl font-bold mb-4">{post.title}</h1>
      <PostContent content={post.body} />

      {/* This one needs interactivity — so it's a Client Component */}
      <CommentSection postId={post.id} />
    </article>
  )
}

Notice what is absent: no useState, no useEffect, no API route wrapper and no loading-state bookkeeping. Data is awaited exactly as in any other async function, which is the core of the model.

Two practical notes. From Next.js 15 onwards, params is passed as a Promise, so the page would await params before reading slug; check the version you are on. And for a real not-found case, calling notFound() from next/navigation returns a proper 404 status rather than a normal page with an error message.

The comment box is different. It keeps the draft text in state and reacts to typing and clicks, so it has to run in the browser. The "use client" directive at the top of the file marks it as a Client Component:

// components/CommentSection.tsx
"use client" // opts into browser rendering

import { useState } from "react"

type Props = {
  postId: string
}

export function CommentSection({ postId }: Props) {
  const [comment, setComment] = useState("")

  const handleSubmit = async () => {
    await fetch("/api/comments", {
      method: "POST",
      body: JSON.stringify({ postId, comment }),
    })
    setComment("")
  }

  return (
    <div className="mt-8">
      <textarea
        value={comment}
        onChange={(e) => setComment(e.target.value)}
        placeholder="Leave a comment..."
        className="w-full border rounded p-2 text-sm"
      />
      <button
        onClick={handleSubmit}
        className="mt-2 bg-blue-600 text-white px-4 py-2 rounded text-sm"
      >
        Post Comment
      </button>
    </div>
  )
}

Everything interactive lives here: local state for the textarea, a change handler, and a submit handler that posts to an API route and then clears the field. When wiring this up for real, send a Content-Type: application/json header with the request, and consider a Server Action as an alternative to a separate API route.

The resulting split is easy to reason about: the server owns the data, the client owns the interaction.

Rules that keep the boundary clean

  • In the App Router, components are Server Components by default.
  • Add "use client" only where you need state, effects, event handlers or browser APIs such as window and localStorage.
  • Server Components can import and render Client Components, as the page does with CommentSection.
  • Client Components cannot import Server Components, but they can receive them as children or other props, which lets you nest server-rendered content inside an interactive shell.
  • Props passed from server to client must be serialisable: plain data works, functions and class instances do not.
  • Styling is unaffected. Tailwind classes and CSS work the same in both kinds of component.

"use client" also marks a boundary rather than a single file: everything that file imports becomes part of the client bundle. Placing the directive as low in the tree as possible, on the small interactive leaves, keeps the rest on the server.

Wrapping up

The mental model clicks once "where does this run?" becomes the first question you ask about a component, and the App Router's default answer is the server.

  • Server Components move rendering and data access to the server and ship no component JavaScript.
  • Data fetching becomes plain async/await inside the component.
  • Treat "use client" as an opt-in for interactive leaves, not a default.
  • A practical first step: pick one data-fetching component in an existing project, ask whether it truly needs the browser, and convert it if not.

With that model in place, layouts, Suspense streaming, Server Actions and parallel routes become far easier to learn, since each builds on the same server-first foundation.