React Server Components in Production

When RSC actually pays off, where the server-client boundary belongs, and two scars I picked up moving real apps onto it.

It was a Thursday morning, second coffee, and I was staring at a Next.js App Router migration that I’d been told would take “maybe a sprint”. The team had ported about a third of the routes. The home feed page was sending 220 KB of client JavaScript for what was, when you actually looked at it, two server-side database reads and a rendered list. Three nested "use client" boundaries deep, fetching the same author profile from three different components. p95 TTFB was fine. INP on slow mid-range Android was not.

That’s the part of React Server Components nobody puts on the slide. RSC pays off when the server actually does the work. The moment you let client components creep back up the tree, you’ve got the cost of SSR and the cost of a SPA at the same time, and your bundle is angrier than before.

So here’s my position. RSC is worth it for content-heavy, data-fetching surfaces where the server is closer to the database than the user is to your CDN. It’s not worth it for highly interactive UIs that were already humming on TanStack Query. And the boundary placement matters more than anything else you’ll do.

Where the server-client line goes

The mental model I land on after a couple of these migrations. Server components are the default. Client components are a leaf. You drop "use client" as late in the tree as you can get away with, never near the root.

The trap is that one early "use client" propagates downward through imports. If you put it on a layout, every page inside becomes a client tree and you’re back to a SPA wearing an RSC hat.

// app/(feed)/page.tsx
import { Suspense } from 'react'
import { getFeedPage, getCurrentMember } from '@/server/feed'
import { FeedSkeleton } from '@/components/feed/FeedSkeleton'
import { FeedList } from './FeedList'
import { ComposerIsland } from './ComposerIsland'

export const dynamic = 'force-dynamic'

export default async function FeedPage({
  searchParams,
}: {
  searchParams: { cursor?: string }
}) {
  const [member, page] = await Promise.all([
    getCurrentMember(),
    getFeedPage({ cursor: searchParams.cursor, limit: 20 }),
  ])

  return (
    <main>
      <ComposerIsland memberId={member.id} />
      <Suspense fallback={<FeedSkeleton />}>
        <FeedList initial={page} memberId={member.id} />
      </Suspense>
    </main>
  )
}

ComposerIsland is the only client component on this page. It’s a leaf. The feed list itself stays on the server, even though it paginates, because pagination is a server round trip anyway. Everything else, including the post body renderer with its mention parsing and markdown, lives on the server.

The island itself stays narrow on purpose. Local input state, an optimistic insert, and a server action handle the round trip. No global store, no provider tree wrapping the page.

'use client'

import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { postToFeed } from '@/app/actions/feed'

type Props = { memberId: string }

export function ComposerIsland({ memberId }: Props) {
  const [body, setBody] = useState('')
  const [error, setError] = useState<string | null>(null)
  const [isPending, startTransition] = useTransition()
  const router = useRouter()

  function submit() {
    if (!body.trim() || isPending) return
    const draft = body
    setBody('')
    startTransition(async () => {
      const result = await postToFeed({ memberId, body: draft })
      if (!result.ok) {
        setBody(draft)
        setError(result.error)
        return
      }
      setError(null)
      router.refresh()
    })
  }

  return (
    <div data-pending={isPending ? 'true' : undefined}>
      <textarea value={body} onChange={(e) => setBody(e.target.value)} />
      {error ? <p role="alert">{error}</p> : null}
      <button type="button" onClick={submit} disabled={isPending}>
        {isPending ? 'Posting' : 'Post'}
      </button>
    </div>
  )
}

router.refresh() re-fetches the server tree for the current route. Combined with revalidateTag inside the action, the new post shows up in the server-rendered feed without the island ever touching the DOM directly. That’s the pattern. Mutations live on the server. Client state stays local to the island.

Killing waterfalls with parallel fetches

The thing I see most often in RSC code review. Engineers move a component to the server, await one thing, then await the next thing inside it, and the page just sits there. RSC does not magically parallelize what you wrote sequentially. You still have to Promise.all, and you still have to push fetches as high as possible so they start as soon as the route resolves.

I had this exact fight inside the App Router migration we did at a major creator-economy SaaS. A profile page was awaiting the user, then awaiting their courses, then awaiting their community memberships. Three sequential round trips to PostgreSQL behind an internal API, p95 around 1.8 seconds on the server side. We rewrote the data layer as a single server function returning everything the page needed, parallelized, and TTFB landed near 280 ms. No client code changed.

// server/feed.ts
import 'server-only'
import { unstable_cache } from 'next/cache'
import { db } from '@/server/db'
import { getSessionMember } from '@/server/auth'
import type { FeedItem } from '@/types/feed'

export async function getFeedPage(args: {
  cursor?: string
  limit: number
}): Promise<{ items: FeedItem[]; nextCursor: string | null }> {
  const member = await getSessionMember()

  return unstable_cache(
    async () => {
      const rows = await db.feedItem.findMany({
        where: { audienceId: { in: member.audienceIds } },
        orderBy: { createdAt: 'desc' },
        take: args.limit + 1,
        cursor: args.cursor ? { id: args.cursor } : undefined,
      })

      const items = rows.slice(0, args.limit)
      const nextCursor = rows.length > args.limit ? items[items.length - 1].id : null
      return { items, nextCursor }
    },
    ['feed-page', member.id, args.cursor ?? 'head'],
    { revalidate: 30, tags: [`member:${member.id}:feed`] },
  )()
}

Two things doing real work there. 'server-only' at the top guarantees this file never accidentally ends up bundled to the client, and the build fails loudly if it does. The unstable_cache tag is what lets revalidateTag('member:123:feed') punch a hole when the member posts something new, which is the bit you actually want for social-shaped products.

Incremental adoption beats the rewrite

A second scar, same engagement. I’d been migrating our design system to Atomic CSS over a quarter, around 70 percent done. A Friday afternoon, five PRs merging older components at once. CI passed. Deploy ran. Half an hour later, support pinged. Creator bio sections had collapsed to zero height because a global CSS reset bundled with the new design system was nuking padding-top and margin on <section> tags.

A teammate hotfixed the bio container. Within an hour, three more reports came in for similarly-collapsed sections elsewhere. The global reset had implications we hadn’t traced.

Real fix was a rollback of the bundle, then re-scoping the reset to the new design-system surface via a data attribute boundary instead of emitting it globally. Re-shipped two days later, with Chromatic visual regression on the top 50 routes and tight diff thresholds.

The connection to RSC: do not try to flip a whole codebase to App Router in one sprint. Move it route by route, oldest data-fetching pages first, leaving interactive client-heavy surfaces last. Hold the Pages Router and App Router side by side. Visual regression catches the things type-checking can’t.

Takeaways

  • Server is the default. "use client" is a leaf, dropped as late as the tree allows.
  • Co-locate data fetches at the page, parallelize them, and tag the cache for surgical invalidation.
  • Move route by route. Don’t run Pages and App Router migrations as a big-bang.
  • RSC pays off when the server is close to the database. Already-fast SPAs don’t need it.

Thanks for reading. If you’ve got thoughts, send them my way.

© 2026 Akin Gundogdu. All Rights Reserved.