background-shape
React Server Components in Next.js 13, The Mental Model
February 16, 2023 · 7 min read · by Muhammad Amal programming

TL;DR — Server components render once on the server and never ship JavaScript / The boundary is one-way: server can compose client, but client can’t import server / Most pages should be server-by-default with small client islands.

The reason React Server Components feel weird is that they break a rule you didn’t know you’d internalized: that every React component runs on the same machine. In Next.js 13’s app router, half your tree runs on the server, half on the client, and the two negotiate through a serialization boundary you have to be aware of.

Once that boundary is in your head, the pieces snap together. This post is the working mental model that finally made server components click for me, building on last week’s first-impressions post.

The two-world model

Every component in app router exists in exactly one of two worlds:

  • Server world. Default. Files without "use client". Run once per request on the server. Can do async, hit the database, read filesystem, use secrets. Cannot use hooks, event handlers, browser APIs.
  • Client world. Opt-in. Files starting with "use client". Run on both the server (for the initial SSR pass) and the browser (for hydration and interaction). Can use hooks, state, effects. Cannot be async at the component level. Cannot directly access server-only resources.

The directive applies to the file, not the component. Once you write "use client" at the top, every component exported from that file is a client component, and so is every component imported into it.

// app/dashboard/stats.tsx — server component (no "use client")
import { db } from "@/lib/db";
import { LiveCounter } from "./live-counter";

export default async function Stats() {
  const total = await db.user.count();
  return (
    <section>
      <h2>Stats</h2>
      <LiveCounter initial={total} />
    </section>
  );
}
// app/dashboard/live-counter.tsx — client component
"use client";
import { useState, useEffect } from "react";

export function LiveCounter({ initial }: { initial: number }) {
  const [n, setN] = useState(initial);
  useEffect(() => {
    const id = setInterval(() => setN(v => v + 1), 5000);
    return () => clearInterval(id);
  }, []);
  return <p>{n} users (counting...)</p>;
}

The Stats component runs once on the server. Its HTML — including the server-rendered initial state of LiveCounter — ships to the browser. LiveCounter then hydrates and takes over.

The one-way boundary

The rule that catches everyone: you cannot import a server component into a client component.

You can pass server components as children:

// app/page.tsx — server
import { ClientLayout } from "./client-layout";
import { ServerWidget } from "./server-widget";

export default function Page() {
  return (
    <ClientLayout>
      <ServerWidget />
    </ClientLayout>
  );
}
// app/client-layout.tsx — client
"use client";
import { ReactNode } from "react";

export function ClientLayout({ children }: { children: ReactNode }) {
  return <div className="layout">{children}</div>;
}

This works because children is data — React knows it’s already rendered into elements by the time it crosses the boundary. But this fails:

// app/client-layout.tsx — client
"use client";
import { ServerWidget } from "./server-widget"; // ← bundles ServerWidget into the client! 

If you import a server component file from a client component file, you’ve forced it to be bundled as client code. Whatever server-only logic it had — database calls, filesystem reads, environment secrets — either won’t work or, worse, will leak.

The mental rule: server components flow down through children, not through import.

What can cross the boundary

Server-to-client props go through serialization. Things that can cross:

  • Primitives: string, number, boolean, null, undefined
  • Plain objects and arrays of the above
  • Promises (Next.js / React serialize the resolved value)
  • Date (serialized as ISO string by default; arrives as Date if you handle it)
  • React elements / ReactNode (the magic children case)

Things that can’t:

  • Functions (no, you can’t pass a callback prop to a client component from a server component)
  • Class instances
  • Symbols
  • Anything with circular refs

The function restriction is the one that bites the most. The mental shift is: instead of passing a callback, the client component imports an action of its own, or you use a form post.

The data fetching primitives

In server components, you have three reasonable ways to fetch:

// 1. Direct DB call (server only)
const users = await db.user.findMany();

// 2. Native fetch, augmented by Next.js with caching
const res = await fetch("https://api.example.com/users", {
  next: { revalidate: 60 },
});
const users = await res.json();

// 3. Force dynamic (no cache, runs every request)
const res = await fetch("https://api.example.com/users", {
  cache: "no-store",
});

The default for fetch is to cache aggressively. If you don’t say otherwise, Next.js will cache the result indefinitely. This is fine for static-ish data and a footgun for user-specific data. Be explicit.

For database calls, there’s no automatic caching — each render hits the DB. If you want to dedupe within a single render (e.g., two components both querying the current user), use React’s cache() function:

// lib/get-user.ts
import { cache } from "react";
import { db } from "./db";

export const getUserById = cache(async (id: string) => {
  return db.user.findUnique({ where: { id } });
});

Now multiple components can call getUserById("u_1") within the same request and only hit the DB once.

Streaming with loading.tsx and <Suspense>

The other architectural feature server components unlock is streaming. A slow data fetch doesn’t block the whole page:

// app/dashboard/loading.tsx — shown while page.tsx is fetching
export default function Loading() {
  return <div>Loading dashboard...</div>;
}

Or finer-grained with Suspense boundaries inside a page:

import { Suspense } from "react";

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

Stats and Activity each do their own async work. The header ships immediately; each section appears as its data resolves. This is honest progressive rendering, not loading skeletons faked on the client.

It works because Next.js 13 streams the HTML in chunks. The first byte is the page shell; subsequent chunks fill in suspended boundaries as they resolve. Browsers happily render incrementally.

When to reach for client components

Server-by-default is the right posture. Reach for "use client" when you need:

  • Interactive state (useState, useReducer)
  • Effects (useEffect, useLayoutEffect)
  • Browser APIs (window, document, localStorage)
  • Event handlers on real DOM events
  • Third-party libraries that themselves need any of the above

Everything else — layout, data, static UI, even loops over fetched data — should be server.

A useful exercise: take a page in the old pages/ model and identify which parts truly need to be interactive. In most apps, it’s small islands inside large static shells. That’s the shape app router optimizes for.

Common Pitfalls

  • Importing server modules from client files. TypeScript won’t always catch this; the runtime error is “module not found” or worse, a working build that leaks secrets. Audit imports during reviews.
  • Forgetting fetch caches by default. A user-specific endpoint with default fetch caching will serve the wrong user’s data. Always set cache: "no-store" or use cookies/headers that bust the cache.
  • Trying to read cookies/headers in a deeply nested component. Use cookies() and headers() from next/headers at the page or layout level; pass values down as props. Don’t sprinkle them everywhere.
  • Passing functions as props across the boundary. Won’t work. Use server actions (once stable later in 2023) or have the client component import the action it needs.
  • Mixing pages/ and app/ data fetching mental models. They don’t compose. Decide per page whether it’s pages/ or app/. Don’t try to share data-fetching abstractions across the boundary.
  • Relying on useEffect for initial data. That’s the old way. If a server component can fetch, it should. Use effects only for things that genuinely need the browser.

Wrapping Up

The mental model is: server-by-default, client-by-need, one-way imports. Once those three rules feel automatic, the rest of the app router stops being weird. Next post in this series will cover TypeScript strict mode config which becomes especially valuable when your server and client components share types.

If a tutorial confuses you, check whether it’s using pages/ or app/. Half the confusion in the community right now is mixed examples.

External reference if you want to go deeper: the official React docs on Server Components.