DEV Community

Atlas Whoff
Atlas Whoff

Posted on

React Server Components in Production: Data Fetching, Streaming, and Client Boundaries

React Server Components (RSC) fundamentally change how you think about data fetching and rendering. After a year of building with them in production, here are the patterns that work and the mistakes that cost time.

The Mental Model Shift

In the old model: everything renders on the client, data fetches in useEffect, loading states everywhere.

In the RSC model: components render on the server by default, data fetches are await calls, no loading state needed for initial render.

// Old pattern -- useEffect data fetching
function UserDashboard() {
  const [user, setUser] = useState(null)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    fetchUser().then(setUser).finally(() => setLoading(false))
  }, [])

  if (loading) return <Spinner />
  return <div>{user?.name}</div>
}

// New pattern -- Server Component
async function UserDashboard() {
  const user = await db.user.findUnique({ where: { id: getCurrentUserId() } })
  return <div>{user?.name}</div>
}
Enter fullscreen mode Exit fullscreen mode

No loading state. No useEffect. No client-side fetch. The component is async — it waits for the data.

The Component Boundary Decision

Every component is a Server Component unless it uses:

  • useState or useReducer
  • useEffect or lifecycle hooks
  • Browser APIs (window, document, etc.)
  • Event handlers (onClick, onChange)
  • Third-party libraries that use the above

If a component needs any of these, add 'use client' at the top. Keep 'use client' boundaries as leaf-level as possible.

// app/dashboard/page.tsx -- Server Component
async function DashboardPage() {
  const [user, projects, stats] = await Promise.all([
    getUser(),
    getProjects(),
    getStats(),
  ])

  return (
    <div>
      <h1>{user.name}</h1>
      <StatsGrid stats={stats} />            {/* Server Component */}
      <ProjectList projects={projects} />     {/* Server Component */}
      <NewProjectButton />                    {/* 'use client' -- has onClick */}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Streaming with Suspense

Render the page shell immediately, stream in slow data:

import { Suspense } from 'react'

async function DashboardPage() {
  // Fast -- render immediately
  const user = await getUser()

  return (
    <div>
      <h1>Welcome, {user.name}</h1>
      {/* Slow query -- streams in after shell */}
      <Suspense fallback={<StatsSkeleton />}>
        <SlowAnalytics userId={user.id} />
      </Suspense>
    </div>
  )
}

// SlowAnalytics fetches its own data -- doesn't block the page
async function SlowAnalytics({ userId }) {
  const stats = await getExpensiveStats(userId) // takes 2 seconds
  return <StatsChart data={stats} />
}
Enter fullscreen mode Exit fullscreen mode

Passing Server Data to Client Components

// Server Component fetches, passes serializable data to client
async function ProductPage({ id }) {
  const product = await db.product.findUnique({ where: { id } })

  return (
    <div>
      <h1>{product.name}</h1>
      {/* Pass only what the client component needs */}
      <AddToCartButton
        productId={product.id}
        price={product.price}
        inStock={product.inventory > 0}
      />
    </div>
  )
}

// 'use client'
function AddToCartButton({ productId, price, inStock }) {
  // Client component -- can use state and event handlers
  const [added, setAdded] = useState(false)
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Server Actions

// Form with Server Action -- no API route needed
async function updateProfile(formData: FormData) {
  'use server'
  const session = await getServerSession()
  await db.user.update({
    where: { id: session.user.id },
    data: { name: formData.get('name') as string },
  })
  revalidatePath('/dashboard/profile')
}

function ProfileForm({ user }) {
  return (
    <form action={updateProfile}>
      <input name='name' defaultValue={user.name} />
      <button type='submit'>Save</button>
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode

Common Mistakes

  1. Fetching in loops: Use Promise.all not for...await
  2. Giant 'use client' boundaries: Moves too much to the client
  3. Passing non-serializable data to client: Functions, class instances, Dates — serialize first
  4. Skipping Suspense: Without it, slow components block the entire page

The AI SaaS Starter at whoffagents.com is built RSC-first: parallel data fetching, Suspense boundaries on slow queries, and minimal use client footprint. $99 one-time.

Top comments (0)