react 5 min read

React Server Components, One Year Later

RSC shipped in Next.js 14. I have been using it for a year. Here is what works, what does not, and what I wish I knew earlier.

I’ve been using React Server Components in production for about a year now. Not a toy project. A real application with authentication, database queries, forms, and the kind of state management that makes you question your career choices.

Here’s what I’ve learned.

What Actually Works

Data Fetching Is Better

This is the one thing RSC genuinely fixed. Before RSC, the data fetching story in React was a mess. You had useEffect on mount, or React Query, or SWR, or getServerSideProps, or getStaticProps. Every project picked a different combination and none of them felt right.

With RSC, you just fetch data in the component:

// app/dashboard/page.tsx
async function DashboardPage() {
  const stats = await db.query('SELECT * FROM dashboard_stats')
  const user = await getUser()

  return (
    <div>
      <h1>Welcome back, {user.name}</h1>
      <StatsGrid data={stats} />
    </div>
  )
}

No useEffect. No loading states in the component. No waterfall of requests from nested components. The server resolves all of this before the client sees anything.

I’ve deleted hundreds of lines of client-side fetching code. React Query is gone from two of my projects. The component tree is simpler because data doesn’t need to be passed down through props from a single fetching point.

Bundle Size Dropped

Server components don’t ship JavaScript to the client. That sentence sounds obvious but the impact was bigger than I expected.

My dashboard app had a 340KB JavaScript bundle (gzipped). After converting most pages to server components, it dropped to 128KB. The components that import date-fns, lodash, or heavy charting libraries but only use them for rendering? Those imports stay on the server now.

# Before RSC migration
Route (app)              Size     First Load JS
/dashboard               142 kB   340 kB
/settings                 89 kB   287 kB

# After RSC migration
Route (app)              Size     First Load JS
/dashboard                12 kB   128 kB
/settings                  8 kB   124 kB

That’s real. Users on slow connections notice.

What Doesn’t Work

The Client/Server Boundary Is Confusing

This is the biggest problem. And after a year, it hasn’t gotten easier. It’s just gotten familiar.

The rule is simple: server components can’t use hooks or browser APIs. Client components can. Mark client components with 'use client'.

In practice, you’re constantly hitting this:

Error: useState only works in Client Components.
Add the "use client" directive at the top of the file to use it.

Or this one, which is worse:

Error: Event handlers cannot be passed to Client Component props.

You want to add an onClick handler to a button inside a server component. You can’t. You need to extract the button into a separate client component file. For a button. One onClick.

// This fails in a server component
<button onClick={() => setOpen(true)}>Open</button>

// You need this instead
// components/OpenButton.tsx
'use client'
export function OpenButton({ children }) {
  const [open, setOpen] = useState(false)
  return <button onClick={() => setOpen(!open)}>{children}</button>
}

After a year I have a components/client/ folder with 23 tiny wrapper components that exist solely to add interactivity to server-rendered pages. It works. But it feels like I’m writing two applications that happen to share a file system.

Caching Is a Maze

Next.js added four layers of caching with RSC: Request Memoization, Data Cache, Full Route Cache, and Router Cache. I spent an afternoon in February reading the docs about all four and I still can’t tell you when exactly the Data Cache invalidates.

I’ve had bugs where stale data persisted for hours because some cache layer held onto a response I expected to be fresh. The fix was always one of:

// Option 1: Opt out of caching
const data = await fetch(url, { cache: 'no-store' })

// Option 2: Time-based revalidation
const data = await fetch(url, { next: { revalidate: 60 } })

// Option 3: On-demand revalidation via server action
revalidatePath('/dashboard')

I now default to cache: 'no-store' for anything that reads from a database and only add caching back when I’ve measured that it matters. The opposite of what the framework suggests, but it’s saved me from debugging phantom stale data.

Server Actions Are Rough

Server actions looked great in the demos. A function that runs on the server, called from a form or a client component. No API route needed.

In practice, error handling is painful. There’s no built-in pattern for returning validation errors to the client. You end up building your own:

'use server'

export async function updateProfile(formData: FormData) {
  const name = formData.get('name') as string
  if (!name || name.length < 2) {
    return { error: 'Name must be at least 2 characters' }
  }

  await db.query('UPDATE users SET name = $1 WHERE id = $2', [name, userId])
  revalidatePath('/profile')
  return { success: true }
}

Every server action in my codebase returns { error?: string, success?: boolean } because there’s no standard way to handle this. Libraries like next-safe-action exist now, but the fact that you need a library for basic form validation tells you something.

What I Wish I Knew Earlier

Start with server components everywhere. Then add 'use client' only when you hit an error. Don’t pre-optimize by guessing which components need client interactivity. Let the compiler tell you.

Keep client components small and at the leaves. A toggle button, a dropdown, a form with state. Don’t make your page layout a client component because one child needs useState.

Don’t fight the cache. Turn it off, ship, then add it back when performance data tells you to. Debugging stale cache is more expensive than a few extra database queries.

Server actions aren’t a replacement for API routes. If you need proper error handling, rate limiting, or middleware, write an API route. Server actions are great for simple form submissions. They’re not great for anything that needs to return structured errors.

Would I Use RSC Again?

Yes. The data fetching improvements alone are worth it. My components are simpler. My bundles are smaller. The mental model is different, but after a year it’s comfortable.

The tooling will catch up. The boundary confusion will get better. TypeScript support for server/client contracts will improve.

But right now, in May 2026, RSC is a real productivity gain wrapped in a bunch of paper cuts. If you’re building something new with Next.js, you’re already using it. If you’re migrating, start with the data layer and work outward.

For practical tools that help with the migration (like checking what your bundles look like before and after), check out the bundle size checker.