Back

Common Mistakes With React Server Components

Common Mistakes With React Server Components

You’ve adopted the Next.js App Router. Server Components are the default. Everything should be faster, simpler, more efficient.

Instead, you’re debugging hydration mismatches at 2 AM, wondering why your bundle grew, and questioning whether that 'use client' directive belongs where you put it.

React Server Components represent a fundamental shift in how React applications work. The server/client boundary isn’t just a deployment concern anymore—it’s an architectural decision you make with every component. Getting this wrong leads to subtle bugs, performance regressions, and code that fights against the framework instead of leveraging it.

This article covers the most common React Server Components mistakes I’ve seen in production codebases and how to avoid them.

Key Takeaways

  • Server Components are the default in Next.js App Router—push 'use client' as far down the component tree as possible to minimize bundle size.
  • Use the server-only package to prevent accidental exposure of sensitive server code to the client bundle.
  • Always convert non-serializable values (like functions and class instances) before passing them from Server to Client Components.
  • Server Actions ('use server') are RPC-style endpoints, not Server Components—validate all inputs and never trust client data.
  • Be explicit about caching behavior with revalidate or cache: 'no-store' since Next.js defaults have changed across versions.

Understanding Server vs Client Components

Before diving into pitfalls, let’s establish the baseline. In the App Router, components are Server Components by default. They run on the server, have no access to browser APIs, and ship zero JavaScript to the client.

Client Components require the 'use client' directive. They can use hooks like useState and useEffect, access browser APIs, and handle user interactions.

The boundary between them is where most mistakes happen.

Overusing the ‘use client’ Directive

The most frequent Next.js App Router RSC pitfall is reaching for 'use client' too early. A component needs useState? Mark it as a client component. Need an onClick handler? Client component.

The problem: 'use client' creates a boundary. Everything that component imports becomes part of the client bundle, even if those imports could have stayed on the server.

// ❌ Entire page becomes a client component
'use client'

import { useState } from 'react'

export default function ProductPage({ product }) {
  const [quantity, setQuantity] = useState(1)
  
  return (
    <div>
      <ProductDetails product={product} />
      <ProductReviews productId={product.id} />
      <QuantitySelector value={quantity} onChange={setQuantity} />
    </div>
  )
}
// ✅ Only the interactive piece is a client component
import ProductDetails from './ProductDetails'
import ProductReviews from './ProductReviews'
import QuantitySelector from './QuantitySelector'

export default function ProductPage({ product }) {
  return (
    <div>
      <ProductDetails product={product} />
      <ProductReviews productId={product.id} />
      <QuantitySelector /> {/* This is the only client component */}
    </div>
  )
}

Push 'use client' as far down the component tree as possible. Isolate interactivity into the smallest components that need it.

Importing Server-Only Code Into Client Components

When a client component imports a module, that entire module (and its dependencies) ships to the browser. Import a database client or a file that reads environment secrets? You’ve just exposed server-only code to the client graph.

// lib/db.js
import 'server-only' // Add this to prevent accidental client imports

export async function getUsers() {
  return db.query('SELECT * FROM users')
}

The server-only package (provided by Next.js) causes a build error if the module is ever imported into a client component. Use it for any code that must never reach the browser.

Passing Non-Serializable Values Across the Boundary

Server Components pass props to Client Components through serialization. Functions, class instances, Map, and Set cannot cross this boundary.

// ❌ Class instances aren't serializable
export default async function UserProfile({ userId }) {
  const user = await getUser(userId)
  return <ClientProfile user={user} /> // user is a class instance
}

// ✅ Convert to a plain object
export default async function UserProfile({ userId }) {
  const user = await getUser(userId)
  return (
    <ClientProfile 
      user={{
        id: user.id,
        name: user.name,
        createdAt: user.createdAt.toISOString()
      }} 
    />
  )
}

Misunderstanding React Server Actions

The 'use server' directive marks functions as Server Actions—callable from the client but executed on the server. It does not make a component a Server Component. Server Components don’t need any directive since they’re the default.

// This is a Server Action, not a Server Component
async function submitForm(formData) {
  'use server'
  await db.insert({ email: formData.get('email') })
}

Server Actions are effectively RPC-style endpoints. Treat them like API routes: validate inputs, handle errors, and never trust client data.

Ignoring the RSC Caching Model

Next.js caching behavior has evolved significantly. Don’t assume fetch calls are cached by default—this varies by Next.js version, route segment configuration, and runtime settings. Be explicit about data freshness.

// Be explicit about caching intentions
const data = await fetch(url, { 
  next: { revalidate: 3600 } // Cache for 1 hour
})

// Or opt out entirely
const data = await fetch(url, { cache: 'no-store' })

Use revalidatePath() and revalidateTag() in Server Actions to invalidate cached data after mutations. The RSC caching model requires intentional decisions about data freshness.

Conclusion

React Server Components reward careful thinking about where code runs. Default to Server Components. Push client boundaries down. Serialize data at the edge. Validate Server Action inputs. Be explicit about caching.

The mental model takes time to internalize, but the payoff—smaller bundles, faster loads, simpler data fetching—is worth the investment.

FAQs

Partially. Server Components cannot use state or effect hooks like useState or useEffect because they run only on the server. However, hooks such as useContext are supported. If your component needs state, effects, or browser APIs, you must add the use client directive to make it a Client Component. Keep these interactive pieces as small and isolated as possible.

Ask yourself whether the component needs interactivity, browser APIs, or React hooks like useState or useEffect. If yes, it must be a Client Component. If it only renders data or fetches from a database, keep it as a Server Component. When in doubt, start with a Server Component and only add use client when the build or runtime explicitly requires it.

The most common cause is placing use client too high in your component tree. When a parent becomes a Client Component, all its imports join the client bundle. Audit your use client directives and push them down to the smallest interactive components. Also check for accidental imports of server-only libraries in client code.

The use client directive marks a component to run in the browser with access to hooks and browser APIs. The use server directive marks a function as a Server Action, which is callable from the client but executes on the server. Server Components need no directive at all since they are the default in the Next.js App Router.

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers. Check our GitHub repo and join the thousands of developers in our community.

OpenReplay