Skip to content

React Server Components: Eight Months In, What I Actually Ship

React Server Components: Eight Months In, What I Actually Ship

Confession: I was going to write this post back in November, then I opened the dashboard I’d just migrated to Server Components and noticed a waterfall I’d accidentally re-introduced. So I closed the draft, fixed the bug, and told myself I’d come back to it once I actually understood what I’d shipped. That was eight months ago. I think I understand it now. Probably.

If you’ve been putting off touching Server Components because the marketing copy made it sound like a paradigm shift, the actually useful framing is much smaller: a Server Component is a function that runs on your server, returns JSX, and its source code never reaches the browser. That’s it. The rest is just figuring out where to draw the boundary between server and client, which is where most of the interesting bugs live.

I’ve been running an App Router Next.js app in production for about eight months now. Here’s what stuck, what I got wrong, and what I still reach for client components for.

The “use client” boundary is a one-way door, and that’s the whole game

The thing the docs say but I had to learn the hard way: "use client" doesn’t just mark a component as client-side. It marks the boundary. Everything imported from that file, and everything rendered as a child of those components, gets bundled and shipped to the browser.

Here’s the version of this I shipped on day one, which I’m not proud of:

// app/dashboard/page.jsx
"use client";

import { useQuery } from "@tanstack/react-query";
import DashboardChart from "./DashboardChart";

export default function DashboardPage() {
  const { data } = useQuery({ queryKey: ["metrics"], queryFn: fetchMetrics });
  return <DashboardChart data={data} />;
}

I’d slapped "use client" at the top of the page because I needed a hook somewhere down in the tree. That single directive turned the entire page into a client bundle. Same fetch waterfall I had in the old Pages Router app, same bundle size, none of the streaming. I’d “migrated” to RSC and gotten zero of the benefits.

The fix is to push the boundary down as far as possible:

// app/dashboard/page.jsx (server component, no directive)
import DashboardChart from "./DashboardChart"; // can be a client component

export default async function DashboardPage() {
  const metrics = await getMetrics(); // runs on the server
  return <DashboardChart data={metrics} />;
}

Now the data fetch happens server-side during render, the chart’s interactivity stays client-side, and only the chart and its deps ship to the browser. The page itself is HTML by the time it hits the user. The React docs on Server Components put this plainly once you read past the intro paragraphs, but I had to ship the bad version first to understand why.

The pattern I use 90% of the time

Most of my pages now look like this: server component on the outside, client component island for the interactive bit, props passed across the boundary. The mental model that finally clicked: server components compose like normal React, but anything passed across the boundary has to be serializable. Functions don’t cross. Class instances don’t cross. Dates cross but you might be surprised by what they look like on the other side.

// Server component
export default async function ProjectPage({ params }) {
  const project = await db.project.findUnique({ where: { id: params.id } });
  const tasks = await db.task.findMany({ where: { projectId: params.id } });

  return (
    <main>
      <h1>{project.name}</h1>
      <TaskList initialTasks={tasks} projectId={project.id} />
    </main>
  );
}

// app/projects/[id]/TaskList.jsx
"use client";

import { useState, useOptimistic } from "react";

export default function TaskList({ initialTasks, projectId }) {
  const [tasks, setTasks] = useState(initialTasks);
  // ...interactive logic
}

The server does the database work, hands the client the data it needs, and the client handles the parts that have to feel responsive. It’s not novel as an idea. I was doing roughly this with getServerSideProps for years. The difference is that I no longer have to write a separate API route to refetch after a mutation, and the streaming means the shell renders before the slow part finishes.

Where Server Components quietly saved me

The wins I didn’t expect when I started:

Database calls in components stopped feeling weird. When you can await a Prisma query directly in a component, the temptation to invent a “data layer” abstraction goes away. I deleted three custom hooks (useProject, useTasks, useTeamMembers) the week I migrated, because they existed only to wrap a fetch to my own API. The data fetching now lives next to the thing that needs the data.

The bundle dropped by about 35%. Most of it was libraries that no client component actually needed: the markdown renderer, the syntax highlighter, the schema validation library. They all moved server-side without me changing the code, just by virtue of being imported from server components. The first time I ran the build report after the migration I thought it was broken.

Auth checks stopped being a useEffect dance. I now check the session at the top of the server component, and either render the page or redirect. No flash of unauthorized content, no client-side loading state for “are you logged in.” Just synchronous code that runs before any HTML goes out.

Streaming made our slowest pages feel fast. The reports page has one query that takes about 800ms because we’re aggregating across a couple million rows. With <Suspense> boundaries I can ship the header and the navigation immediately, then stream the slow chart in when it’s ready. I wrote more about Suspense patterns in 2026, which is the same principle composing a little differently in the new model.

The three things that still bite me

If anyone tells you Server Components are simple, ask them about these:

1. Accidentally importing a server-only module into a client component. The error messages have improved a lot, but I still hit this when I refactor. The trick I use now: any module with a db. call or a process.env.SECRET_* reference gets a import "server-only" line at the top, which forces a build error if anything tries to drag it client-side. Costs nothing, catches the bug at compile time.

2. Forgetting that Server Components don’t re-render on the client. When a server component runs, it runs once per request. If you need values that change based on client state, you have to lift that state up into a client component, or use revalidatePath / revalidateTag to trigger a server-side refresh. I lost an afternoon trying to figure out why a filter dropdown wasn’t updating the list below it. The list was a server component. It was never going to update without a navigation.

3. The serialization edge cases. I had a bug where a Decimal from Prisma was being passed as a prop to a client component, and on the client it became a plain string. The math broke silently. The Next.js docs on serialization cover this, but you have to know to look. My rule now: at the server/client boundary, convert anything weird to a primitive. Numbers, strings, booleans, dates, plain objects of those. Anything else, I assume the boundary will mangle.

When I reach for a client component instead

Server Components aren’t the answer to everything. Things that still go client-side, no apologies:

  • Anything with useState or useEffect. Obvious, but worth saying.
  • Search inputs, filters, anything that should respond in under 50ms. A round trip through a server action is fine for mutations. It’s not fine for the search-as-you-type feel.
  • Components that need browser APIs. localStorage, IntersectionObserver, window, document. The server doesn’t have these and pretending otherwise gets ugly.
  • Real-time UI. Websockets, SSE, anything subscription-based. The server component runs once, so it can’t subscribe to anything.

I write more about this kind of architecture-vs-pragmatism trade-off in my work and notes at abrarqasim.com, since I keep ending up back at the same place: pick the simplest tool that actually handles the user-facing requirement, then optimize when you have a measurement.

One thing to try this week

If you’re running an App Router app and haven’t audited where your "use client" boundaries sit, here’s the experiment. Grep for "use client" across your project. For each one, look at what’s imported in that file. If any of those imports is a heavy library (charts, markdown, validation, dates) that you’re only using for display, see whether the parent could become a server component and pass already-formatted data down.

In my codebase, that single audit moved about 200KB of JS to server-only. The site felt the same to me as a developer. To users on slow phones, the difference was real. There’s also a side benefit I didn’t expect: when you read a tree of components and most of them are server components, the data flow is much easier to follow. There’s no useEffect-fetched mystery state. It’s just: this came from the database, this got passed down, here’s the bit the user can click.

I’m still finding edges of this model. The one I’m chewing on now is how to handle long-running streaming responses. Server Actions kind of work for it, but the ergonomics aren’t great yet. I wrote up some early thoughts on Server Action mistakes I made and I’ll probably revisit when React 20 lands. For now, the boundary-down-as-far-as-possible rule has held up across two production apps and a handful of side projects, and that’s about as strong an endorsement as I’ll give any framework feature.