{"id":234,"date":"2026-05-15T05:00:18","date_gmt":"2026-05-15T05:00:18","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/react-server-components-eight-months-in-production\/"},"modified":"2026-05-15T05:00:18","modified_gmt":"2026-05-15T05:00:18","slug":"react-server-components-eight-months-in-production","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/react-server-components-eight-months-in-production\/","title":{"rendered":"React Server Components: Eight Months In, What I Actually Ship"},"content":{"rendered":"<p>Confession: I was going to write this post back in November, then I opened the dashboard I&rsquo;d just migrated to Server Components and noticed a waterfall I&rsquo;d accidentally re-introduced. So I closed the draft, fixed the bug, and told myself I&rsquo;d come back to it once I actually understood what I&rsquo;d shipped. That was eight months ago. I think I understand it now. Probably.<\/p>\n<p>If you&rsquo;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&rsquo;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.<\/p>\n<p>I&rsquo;ve been running an App Router Next.js app in production for about eight months now. Here&rsquo;s what stuck, what I got wrong, and what I still reach for client components for.<\/p>\n<h2 id=\"the-use-client-boundary-is-a-one-way-door-and-thats-the-whole-game\">The &ldquo;use client&rdquo; boundary is a one-way door, and that&rsquo;s the whole game<\/h2>\n<p>The thing the docs say but I had to learn the hard way: <code>\"use client\"<\/code> doesn&rsquo;t just mark a component as client-side. It marks the <em>boundary<\/em>. Everything imported from that file, and everything rendered as a child of those components, gets bundled and shipped to the browser.<\/p>\n<p>Here&rsquo;s the version of this I shipped on day one, which I&rsquo;m not proud of:<\/p>\n<pre><code class=\"language-jsx\">\/\/ app\/dashboard\/page.jsx\n&quot;use client&quot;;\n\nimport { useQuery } from &quot;@tanstack\/react-query&quot;;\nimport DashboardChart from &quot;.\/DashboardChart&quot;;\n\nexport default function DashboardPage() {\n  const { data } = useQuery({ queryKey: [&quot;metrics&quot;], queryFn: fetchMetrics });\n  return &lt;DashboardChart data={data} \/&gt;;\n}\n<\/code><\/pre>\n<p>I&rsquo;d slapped <code>\"use client\"<\/code> 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&rsquo;d &ldquo;migrated&rdquo; to RSC and gotten zero of the benefits.<\/p>\n<p>The fix is to push the boundary down as far as possible:<\/p>\n<pre><code class=\"language-jsx\">\/\/ app\/dashboard\/page.jsx (server component, no directive)\nimport DashboardChart from &quot;.\/DashboardChart&quot;; \/\/ can be a client component\n\nexport default async function DashboardPage() {\n  const metrics = await getMetrics(); \/\/ runs on the server\n  return &lt;DashboardChart data={metrics} \/&gt;;\n}\n<\/code><\/pre>\n<p>Now the data fetch happens server-side during render, the chart&rsquo;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 <a href=\"https:\/\/react.dev\/reference\/rsc\/server-components\" rel=\"nofollow noopener\" target=\"_blank\">React docs on Server Components<\/a> put this plainly once you read past the intro paragraphs, but I had to ship the bad version first to understand why.<\/p>\n<h2 id=\"the-pattern-i-use-90-of-the-time\">The pattern I use 90% of the time<\/h2>\n<p>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&rsquo;t cross. Class instances don&rsquo;t cross. Dates cross but you might be surprised by what they look like on the other side.<\/p>\n<pre><code class=\"language-jsx\">\/\/ Server component\nexport default async function ProjectPage({ params }) {\n  const project = await db.project.findUnique({ where: { id: params.id } });\n  const tasks = await db.task.findMany({ where: { projectId: params.id } });\n\n  return (\n    &lt;main&gt;\n      &lt;h1&gt;{project.name}&lt;\/h1&gt;\n      &lt;TaskList initialTasks={tasks} projectId={project.id} \/&gt;\n    &lt;\/main&gt;\n  );\n}\n\n\/\/ app\/projects\/[id]\/TaskList.jsx\n&quot;use client&quot;;\n\nimport { useState, useOptimistic } from &quot;react&quot;;\n\nexport default function TaskList({ initialTasks, projectId }) {\n  const [tasks, setTasks] = useState(initialTasks);\n  \/\/ ...interactive logic\n}\n<\/code><\/pre>\n<p>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&rsquo;s not novel as an idea. I was doing roughly this with <code>getServerSideProps<\/code> 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.<\/p>\n<h2 id=\"where-server-components-quietly-saved-me\">Where Server Components quietly saved me<\/h2>\n<p>The wins I didn&rsquo;t expect when I started:<\/p>\n<p><strong>Database calls in components stopped feeling weird.<\/strong> When you can <code>await<\/code> a Prisma query directly in a component, the temptation to invent a &ldquo;data layer&rdquo; abstraction goes away. I deleted three custom hooks (<code>useProject<\/code>, <code>useTasks<\/code>, <code>useTeamMembers<\/code>) the week I migrated, because they existed only to wrap a <code>fetch<\/code> to my own API. The data fetching now lives next to the thing that needs the data.<\/p>\n<p><strong>The bundle dropped by about 35%.<\/strong> 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.<\/p>\n<p><strong>Auth checks stopped being a useEffect dance.<\/strong> 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 &ldquo;are you logged in.&rdquo; Just synchronous code that runs before any HTML goes out.<\/p>\n<p><strong>Streaming made our slowest pages feel fast.<\/strong> The reports page has one query that takes about 800ms because we&rsquo;re aggregating across a couple million rows. With <code>&lt;Suspense&gt;<\/code> boundaries I can ship the header and the navigation immediately, then stream the slow chart in when it&rsquo;s ready. I wrote more about <a href=\"https:\/\/abrarqasim.com\/blog\/react-suspense-2026-the-patterns-i-actually-use\" rel=\"noopener\">Suspense patterns in 2026<\/a>, which is the same principle composing a little differently in the new model.<\/p>\n<h2 id=\"the-three-things-that-still-bite-me\">The three things that still bite me<\/h2>\n<p>If anyone tells you Server Components are simple, ask them about these:<\/p>\n<p><strong>1. Accidentally importing a server-only module into a client component.<\/strong> The error messages have improved a lot, but I still hit this when I refactor. The trick I use now: any module with a <code>db.<\/code> call or a <code>process.env.SECRET_*<\/code> reference gets a <code>import \"server-only\"<\/code> 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.<\/p>\n<p><strong>2. Forgetting that Server Components don&rsquo;t re-render on the client.<\/strong> 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 <code>revalidatePath<\/code> \/ <code>revalidateTag<\/code> to trigger a server-side refresh. I lost an afternoon trying to figure out why a filter dropdown wasn&rsquo;t updating the list below it. The list was a server component. It was never going to update without a navigation.<\/p>\n<p><strong>3. The serialization edge cases.<\/strong> I had a bug where a <code>Decimal<\/code> 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 <a href=\"https:\/\/nextjs.org\/docs\/app\/building-your-application\/rendering\/server-components#sharing-data-between-server-and-client-components\" rel=\"nofollow noopener\" target=\"_blank\">Next.js docs on serialization<\/a> 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.<\/p>\n<h2 id=\"when-i-reach-for-a-client-component-instead\">When I reach for a client component instead<\/h2>\n<p>Server Components aren&rsquo;t the answer to everything. Things that still go client-side, no apologies:<\/p>\n<ul>\n<li><strong>Anything with <code>useState<\/code> or <code>useEffect<\/code>.<\/strong> Obvious, but worth saying.<\/li>\n<li><strong>Search inputs, filters, anything that should respond in under 50ms.<\/strong> A round trip through a server action is fine for mutations. It&rsquo;s not fine for the search-as-you-type feel.<\/li>\n<li><strong>Components that need browser APIs.<\/strong> <code>localStorage<\/code>, <code>IntersectionObserver<\/code>, <code>window<\/code>, <code>document<\/code>. The server doesn&rsquo;t have these and pretending otherwise gets ugly.<\/li>\n<li><strong>Real-time UI.<\/strong> Websockets, SSE, anything subscription-based. The server component runs once, so it can&rsquo;t subscribe to anything.<\/li>\n<\/ul>\n<p>I write more about this kind of architecture-vs-pragmatism trade-off in my <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">work and notes at abrarqasim.com<\/a>, 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.<\/p>\n<h2 id=\"one-thing-to-try-this-week\">One thing to try this week<\/h2>\n<p>If you&rsquo;re running an App Router app and haven&rsquo;t audited where your <code>\"use client\"<\/code> boundaries sit, here&rsquo;s the experiment. Grep for <code>\"use client\"<\/code> across your project. For each one, look at what&rsquo;s imported in that file. If any of those imports is a heavy library (charts, markdown, validation, dates) that you&rsquo;re only using for display, see whether the parent could become a server component and pass already-formatted data down.<\/p>\n<p>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&rsquo;s also a side benefit I didn&rsquo;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&rsquo;s no <code>useEffect<\/code>-fetched mystery state. It&rsquo;s just: this came from the database, this got passed down, here&rsquo;s the bit the user can click.<\/p>\n<p>I&rsquo;m still finding edges of this model. The one I&rsquo;m chewing on now is how to handle long-running streaming responses. Server Actions kind of work for it, but the ergonomics aren&rsquo;t great yet. I wrote up some early thoughts on <a href=\"https:\/\/abrarqasim.com\/blog\/nextjs-server-actions-mistakes-i-made\" rel=\"noopener\">Server Action mistakes I made<\/a> and I&rsquo;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&rsquo;s about as strong an endorsement as I&rsquo;ll give any framework feature.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Eight months running React Server Components in production. The boundary patterns that worked, the bugs I shipped, and when I still pick client components.<\/p>\n","protected":false},"author":2,"featured_media":233,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"Eight months running React Server Components in production. The boundary patterns that worked, the bugs I shipped, and when I still pick client components.","rank_math_focus_keyword":"server components react","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[138,35],"tags":[99,38,61,41,43,270,269],"class_list":["post-234","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-frontend","category-web-development","tag-app-router","tag-frontend","tag-nextjs","tag-react","tag-react-19","tag-rsc","tag-server-components"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/234","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=234"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/234\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/233"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=234"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=234"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=234"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}