DEV Community

Wilson Xu
Wilson Xu

Posted on

React Server Components: A Complete Guide to the Future of React

React Server Components: A Complete Guide to the Future of React

React Server Components (RSC) represent the most significant architectural shift in React since hooks were introduced in 2019. But despite being stable in Next.js App Router since 2023, they remain one of the most misunderstood features in the React ecosystem. Developers confuse them with SSR, fight mysterious serialization errors, and unknowingly recreate the waterfall patterns they were trying to escape.

This guide cuts through the confusion. We'll build a complete mental model from first principles, work through every major pattern — data fetching, streaming, Server Actions, caching — and tackle the real mistakes that trip up experienced developers. All examples use Next.js 14/15 App Router with TypeScript.


1. What Are React Server Components (Not SSR!)

The first thing to understand about React Server Components is what they are not: they are not Server-Side Rendering.

SSR is a rendering strategy. When you use SSR, your React components render on the server to produce an HTML string, that HTML is sent to the browser, and then React "hydrates" the page — attaching event listeners and making the page interactive. The component code ships to the browser. SSR runs on every request (or is cached). Components re-render on the client after hydration.

RSC is a component type. A Server Component renders on the server and sends a serialized description of its output — the RSC payload — to the client. The component's JavaScript code never reaches the browser. The component cannot use state, effects, or browser APIs. It never re-renders after the initial server render.

Here is the clearest way to see the difference:

// Traditional SSR component (Pages Router _app.tsx style)
// This runs on server AND client
// Its JavaScript bundles to the browser
function ProductCard({ product }) {
  useEffect(() => {
    analytics.track('product_viewed', { id: product.id })
  }, [])

  return <div>{product.name}</div>
}

// React Server Component (App Router, no 'use client' directive)
// This runs ONLY on the server
// Its JavaScript NEVER reaches the browser
async function ProductCard({ productId }: { productId: string }) {
  // Direct database access — impossible in traditional SSR without API routes
  const product = await db.product.findUnique({ where: { id: productId } })

  return <div>{product.name}</div>
}
Enter fullscreen mode Exit fullscreen mode

The second component's code — including the db import and all its transitive dependencies — never ships to the browser. If db is Prisma (a 100KB+ library), none of that lands in your bundle.

This is the fundamental value proposition of RSC: you can make your server-side code a first-class part of your component tree without any of it touching the client bundle.


2. Server vs. Client Components: The Mental Model

Every component in a Next.js App Router application is one of two types:

Server Components (the default):

  • Run exclusively on the server at request time
  • Can await data directly in the component body
  • Can access databases, file systems, environment secrets
  • Can import server-only packages (ORMs, PDF generators, etc.)
  • Have no state, no effects, no event handlers
  • Never re-render after the initial server render
  • Their JS code never ships to the browser

Client Components (marked with 'use client'):

  • Run on the client (and also on the server during SSR for the initial HTML)
  • Can use all React hooks (useState, useEffect, useContext, etc.)
  • Can attach event handlers and access browser APIs
  • Re-render in response to state and prop changes
  • Their JS code ships to the browser

The decision rule is straightforward: start with a Server Component. Add 'use client' only when you need state, effects, event handlers, or browser APIs.

// app/dashboard/page.tsx
// Server Component by default — no directive needed
export default async function DashboardPage() {
  const user = await getCurrentUser()         // direct DB call
  const metrics = await getDashboardMetrics() // direct DB call

  return (
    <main className="p-6">
      <h1>Welcome, {user.name}</h1>
      <MetricsGrid metrics={metrics} />
      {/* This child component needs interactivity */}
      <DateRangePicker />
    </main>
  )
}
Enter fullscreen mode Exit fullscreen mode
// components/DateRangePicker.tsx
'use client' // needs useState + browser APIs

import { useState } from 'react'

export function DateRangePicker() {
  const [range, setRange] = useState({ from: null, to: null })
  // ... picker logic
  return <div>...</div>
}
Enter fullscreen mode Exit fullscreen mode

One common mental model that helps: think of your UI as a mostly-static document with interactive "islands." The document lives on the server. The islands live on the client. RSC makes this natural to express in React's component model.


3. The Component Tree: Mixing Server and Client

The most counterintuitive part of RSC is how server and client components compose together. The rules are:

  1. Server components can import and render client components — this is the most common pattern
  2. Client components cannot import server components — this would break the isolation
  3. Client components CAN receive server components as children props — this is the escape hatch
// This WORKS: server component renders client component
// app/page.tsx (server component)
import { InteractiveWidget } from './InteractiveWidget'

export default async function Page() {
  const data = await fetchData()
  return (
    <div>
      <StaticContent data={data} />
      <InteractiveWidget /> {/* client component — works fine */}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode
// This BREAKS: client component importing server component
'use client'
import { ServerOnlyPanel } from './ServerOnlyPanel' // Error!

export function ClientLayout() {
  return <div><ServerOnlyPanel /></div>
}
Enter fullscreen mode Exit fullscreen mode

The reason for rule 2: when a file is marked 'use client', it becomes a client module. All its imports must also be available on the client. A server component — which may import fs, prisma, or other server-only modules — cannot be bundled for the client.

But there is a clean workaround using composition:

// components/ClientShell.tsx
'use client'
import { useState } from 'react'

export function ClientShell({ children }: { children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
      {isOpen && children}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode
// app/page.tsx (server component)
import { ClientShell } from './ClientShell'
import { ServerDataPanel } from './ServerDataPanel' // server component!

export default function Page() {
  return (
    <ClientShell>
      <ServerDataPanel /> {/* server component passed as children — works! */}
    </ClientShell>
  )
}
Enter fullscreen mode Exit fullscreen mode

The children pattern lets client components provide interactivity while server components supply server-rendered content inside them. This is how you build complex interactive layouts without sacrificing server rendering for your data-heavy content.


4. Data Fetching Patterns in RSC

RSC changes data fetching from an imperative ceremony (useEffect → set state → render) into a declarative, co-located operation. Here are the patterns that matter.

Pattern 1: Co-locate Fetches with Their Components

The old pattern was prop drilling: fetch everything at the top, drill it down. RSC enables a better approach — each component fetches exactly what it needs:

// app/product/[id]/page.tsx
export default function ProductPage({ params }: { params: { id: string } }) {
  return (
    <div>
      <ProductDetails id={params.id} />   {/* fetches product */}
      <ProductReviews id={params.id} />   {/* fetches reviews */}
      <RelatedProducts id={params.id} />  {/* fetches related */}
    </div>
  )
}

// Each fetches its own data independently, in parallel
async function ProductDetails({ id }: { id: string }) {
  const product = await db.product.findUnique({ where: { id } })
  return <div className="product-details">...</div>
}

async function ProductReviews({ id }: { id: string }) {
  const reviews = await db.review.findMany({ where: { productId: id } })
  return <ReviewList reviews={reviews} />
}
Enter fullscreen mode Exit fullscreen mode

Next.js starts rendering all three sibling components as soon as their parent renders. They fetch in parallel.

Pattern 2: Eliminate Waterfalls with Promise.all

When a single component needs multiple data sources, use Promise.all to fetch them simultaneously:

// Bad — sequential, slow (~190ms total)
async function UserProfile({ userId }: { userId: string }) {
  const user = await getUser(userId)           // ~50ms
  const posts = await getUserPosts(userId)     // ~80ms (waits for user)
  const stats = await getUserStats(userId)     // ~60ms (waits for both)

  return <ProfileLayout user={user} posts={posts} stats={stats} />
}

// Good — parallel, fast (~80ms total, slowest wins)
async function UserProfile({ userId }: { userId: string }) {
  const [user, posts, stats] = await Promise.all([
    getUser(userId),
    getUserPosts(userId),
    getUserStats(userId),
  ])

  return <ProfileLayout user={user} posts={posts} stats={stats} />
}
Enter fullscreen mode Exit fullscreen mode

Pattern 3: Deduplicate with React's cache()

React automatically deduplicates fetch() calls with identical URLs within a single render. For database queries (using Prisma, Drizzle, etc.), use the cache function from React:

// lib/queries.ts
import { cache } from 'react'
import { db } from './database'

// Wrap DB queries in cache() — identical calls within one render
// will reuse the same result without hitting the database twice
export const getUser = cache(async (userId: string) => {
  console.log('DB query for user', userId) // only logs once even if called multiple times
  return db.user.findUnique({
    where: { id: userId },
    include: { profile: true },
  })
})
Enter fullscreen mode Exit fullscreen mode
// UserAvatar.tsx — calls getUser(id)
// UserName.tsx — also calls getUser(id)
// UserBadge.tsx — also calls getUser(id)
// Result: only ONE database query, regardless of how many components call it
Enter fullscreen mode Exit fullscreen mode

This means you can safely call getUser(id) in any component that needs it without worrying about N+1 database queries.


5. Streaming with Suspense

Streaming is RSC's answer to "what does the user see while data loads?" Instead of waiting for all server components to resolve before sending any HTML, streaming progressively sends content as each component finishes.

The API is React's <Suspense> boundary.

// app/dashboard/page.tsx
import { Suspense } from 'react'

export default function DashboardPage() {
  return (
    <div className="dashboard-grid">
      {/* Fast: metrics query is indexed — resolves in ~50ms */}
      <Suspense fallback={<MetricsSkeleton />}>
        <MetricsPanel />
      </Suspense>

      {/* Medium: needs joins — resolves in ~200ms */}
      <Suspense fallback={<OrdersSkeleton />}>
        <RecentOrders />
      </Suspense>

      {/* Slow: ML-powered recommendations — resolves in ~800ms */}
      <Suspense fallback={<RecommendationsSkeleton />}>
        <Recommendations />
      </Suspense>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

What happens:

  1. The page shell (layout, navigation) renders immediately
  2. MetricsPanel content streams in at ~50ms
  3. RecentOrders content streams in at ~200ms
  4. Recommendations content streams in at ~800ms

Without streaming, the page would wait 800ms before showing anything. With streaming, meaningful content appears in 50ms. Time to First Byte improves. Perceived performance improves dramatically.

Build skeletons that match the shape of the actual content — they reduce layout shift and make the loading experience feel intentional:

// components/skeletons/MetricsSkeleton.tsx
export function MetricsSkeleton() {
  return (
    <div className="grid grid-cols-4 gap-4 animate-pulse">
      {Array.from({ length: 4 }).map((_, i) => (
        <div key={i} className="rounded-lg border p-4 space-y-2">
          <div className="h-4 w-24 bg-gray-200 rounded" />
          <div className="h-8 w-16 bg-gray-200 rounded" />
          <div className="h-3 w-32 bg-gray-100 rounded" />
        </div>
      ))}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Next.js also provides loading.tsx as an automatic Suspense boundary for entire route segments:

app/
  dashboard/
    loading.tsx   ← automatic Suspense fallback for this segment
    page.tsx      ← automatically wrapped in <Suspense>
    layout.tsx
Enter fullscreen mode Exit fullscreen mode
// app/dashboard/loading.tsx
export default function Loading() {
  return <DashboardSkeleton />
}
Enter fullscreen mode Exit fullscreen mode

6. Server Actions: Forms and Mutations

Server Actions are async functions that run on the server but can be called from client components. They are the RSC answer to API routes for mutations — you write a function, mark it 'use server', and call it directly from a form or event handler.

// app/actions.ts
'use server'

import { revalidatePath } from 'next/cache'
import { db } from '@/lib/database'
import { z } from 'zod'

const CreatePostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(10),
})

export async function createPost(formData: FormData) {
  // Always validate server-side — Server Actions are public endpoints!
  const parsed = CreatePostSchema.safeParse({
    title: formData.get('title'),
    content: formData.get('content'),
  })

  if (!parsed.success) {
    return { error: parsed.error.flatten() }
  }

  const post = await db.post.create({
    data: {
      title: parsed.data.title,
      content: parsed.data.content,
      authorId: await getCurrentUserId(), // server-side auth check
    },
  })

  revalidatePath('/blog') // invalidate cached blog page
  return { success: true, postId: post.id }
}
Enter fullscreen mode Exit fullscreen mode

Using Server Actions with HTML forms (works without JavaScript — progressive enhancement):

// app/blog/new/page.tsx (server component)
import { createPost } from '../actions'

export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" placeholder="Post title" required />
      <textarea name="content" placeholder="Post content" required />
      <button type="submit">Publish</button>
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode

Using Server Actions with client-side state and pending UI:

// components/CreatePostForm.tsx
'use client'

import { useActionState } from 'react'
import { createPost } from '../actions'

export function CreatePostForm() {
  const [state, formAction, isPending] = useActionState(createPost, null)

  return (
    <form action={formAction}>
      <input
        name="title"
        placeholder="Post title"
        disabled={isPending}
        className="border rounded px-3 py-2 w-full"
      />
      <textarea
        name="content"
        placeholder="Post content"
        disabled={isPending}
        className="border rounded px-3 py-2 w-full mt-2 h-32"
      />

      {state?.error && (
        <div className="text-red-500 text-sm mt-1">
          {state.error.formErrors.join(', ')}
        </div>
      )}

      <button
        type="submit"
        disabled={isPending}
        className="mt-3 px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
      >
        {isPending ? 'Publishing...' : 'Publish'}
      </button>
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode

Critical security note: Server Actions are automatically exposed as HTTP POST endpoints by Next.js. Treat them like public API routes — always validate inputs with a schema (Zod is the standard choice), always check authorization, never trust user-supplied data.


7. Caching Strategies in Next.js App Router

Next.js App Router has four caching layers. Understanding them prevents both stale data bugs and unnecessary re-fetches.

Layer 1: Request Memoization

Deduplicates identical fetch() calls within a single render pass. Automatic — no configuration needed. This is what lets you call getUser(id) in 10 components without 10 database queries.

Scope: Single request, reset per request.

Layer 2: Data Cache

Persists fetched data across requests and deployments on the server. Controlled via fetch options:

// Cache indefinitely (default, or force-cache)
const data = await fetch('https://api.example.com/config', {
  cache: 'force-cache',
})

// Never cache — always fetch fresh data
const data = await fetch('https://api.example.com/live-price', {
  cache: 'no-store',
})

// Revalidate after N seconds (Incremental Static Regeneration)
const data = await fetch('https://api.example.com/products', {
  next: { revalidate: 3600 }, // refresh hourly
})

// Tag-based invalidation — most powerful pattern
const data = await fetch('https://api.example.com/products', {
  next: { tags: ['products', 'catalog'] },
})
Enter fullscreen mode Exit fullscreen mode

Scope: Persistent across requests until invalidated or expired.

Layer 3: Full Route Cache

Caches the complete rendered RSC payload + HTML for a route. Applied automatically to routes with no dynamic data. This is Next.js's equivalent of static generation.

For routes with dynamic data, you can opt into ISR:

// app/blog/[slug]/page.tsx
export const revalidate = 3600 // revalidate this route every hour

export async function generateStaticParams() {
  const posts = await getAllPosts()
  return posts.map((post) => ({ slug: post.slug }))
}
Enter fullscreen mode Exit fullscreen mode

Scope: Persistent until next build or manual revalidation.

Layer 4: Router Cache

Client-side in-memory cache of RSC payloads. Prefetched routes are stored here so navigation feels instant. Managed automatically by Next.js.

Scope: Browser session, cleared on hard navigation.

Tag-Based Cache Invalidation

The most powerful caching pattern combines fetch tags with revalidateTag in Server Actions:

// lib/queries.ts
export async function getProducts(category?: string) {
  return fetch(`/api/products?category=${category ?? ''}`, {
    next: { tags: ['products', category ? `products-${category}` : 'all'] },
  }).then(r => r.json())
}
Enter fullscreen mode Exit fullscreen mode
// app/actions.ts
'use server'
import { revalidateTag } from 'next/cache'

export async function updateProduct(id: string, data: Partial<Product>) {
  await db.product.update({ where: { id }, data })

  // Invalidates all fetches tagged 'products' across all routes
  revalidateTag('products')

  // Or be more targeted:
  revalidateTag(`products-${data.category}`)
}
Enter fullscreen mode Exit fullscreen mode

When revalidateTag('products') runs, every fetch() call tagged with 'products' is invalidated. The next request to any page that uses that data will re-fetch fresh results. This is how you build edit flows that feel instant while keeping data fresh.


8. Common Mistakes and How to Avoid Them

Mistake 1: Adding 'use client' Too Early

The most common RSC mistake is treating 'use client' as the default. Every client component and all its imports bundle for the browser. A single 'use client' at the top of a component that imports a 300KB PDF library ships that library to every user.

Fix: Default to Server Components. Add 'use client' only when you encounter a concrete need (state, event handler, browser API, third-party hook).

Mistake 2: Passing Non-Serializable Props Across the Boundary

Props that cross from a server component to a client component are serialized as JSON. Functions, class instances, Map, Set, and Date objects (unless converted to strings) cannot cross this boundary.

// BREAKS — function prop cannot be serialized
function ServerParent() {
  const handleClick = () => alert('clicked') // function!
  return <ClientButton onClick={handleClick} /> // Error
}

// WORKS — use a Server Action reference instead
'use server'
export async function handleSubmit(formData: FormData) {
  // runs on server
}

// ServerParent.tsx
function ServerParent() {
  return <ClientForm action={handleSubmit} /> // Server Action reference — serializable
}
Enter fullscreen mode Exit fullscreen mode

Mistake 3: Layout Waterfalls

Next.js layouts wrap pages. If your layout awaits data, the page's own data fetching cannot start until the layout resolves:

// SLOW: page data waits for layout data
export default async function DashboardLayout({ children }) {
  const user = await getUser() // page starts after this
  return <Sidebar user={user}>{children}</Sidebar>
}
Enter fullscreen mode Exit fullscreen mode
// FAST: layout and page data fetch in parallel
export default function DashboardLayout({ children }) {
  return (
    <div className="flex">
      <Suspense fallback={<SidebarSkeleton />}>
        <Sidebar /> {/* Sidebar fetches its own user data */}
      </Suspense>
      <main className="flex-1">{children}</main>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Mistake 4: The children Composition Pattern Confusion

You cannot import a server component inside a client component. But many developers don't realize you can pass server components through children:

// WORKS — server components passed as children to client components
// app/page.tsx (server)
import { AnimatedLayout } from './AnimatedLayout'  // client component
import { DataTable } from './DataTable'            // server component

export default function Page() {
  return (
    <AnimatedLayout>
      <DataTable /> {/* server component as children — valid */}
    </AnimatedLayout>
  )
}
Enter fullscreen mode Exit fullscreen mode

The key: AnimatedLayout receives children as an already-resolved RSC payload. It never imports or "knows about" DataTable.

Mistake 5: Using Context in Server Components

useContext is a client-side hook. Server components cannot consume React context.

For global server-side values (current user, locale, feature flags), use Next.js's request APIs:

// Reading cookies/headers in server components
import { cookies, headers } from 'next/headers'

async function ServerComponent() {
  const cookieStore = await cookies()
  const locale = cookieStore.get('locale')?.value ?? 'en'

  const headersList = await headers()
  const userAgent = headersList.get('user-agent')

  return <div lang={locale}>...</div>
}
Enter fullscreen mode Exit fullscreen mode

For client-side shared state, keep context in a client component provider and pass server-rendered content through children:

// providers/ThemeProvider.tsx
'use client'
const ThemeContext = createContext<Theme>(defaultTheme)

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<Theme>(defaultTheme)
  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  )
}

// app/layout.tsx (server component)
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <ThemeProvider>
          {children} {/* server-rendered pages work fine inside */}
        </ThemeProvider>
      </body>
    </html>
  )
}
Enter fullscreen mode Exit fullscreen mode

9. Migration from Pages Router

Migrating from Pages Router to App Router is incremental — Next.js supports both simultaneously. Here is a practical migration path.

Step 1: Identify Your Component Types

Before writing any code, audit your existing pages and components:

  • Components that only display data → candidates for Server Components
  • Components with useState/useEffect → stay as Client Components
  • getServerSideProps / getStaticProps functions → move into server component body

Step 2: Migrate a Leaf Route First

Start with a simple, relatively isolated page — not the home page or dashboard.

// BEFORE: pages/blog/[slug].tsx (Pages Router)
import type { GetStaticProps } from 'next'

interface Props { post: Post }

export default function BlogPost({ post }: Props) {
  return <article dangerouslySetInnerHTML={{ __html: post.content }} />
}

export const getStaticProps: GetStaticProps = async ({ params }) => {
  const post = await getPostBySlug(params.slug as string)
  return { props: { post }, revalidate: 3600 }
}

export async function getStaticPaths() {
  const posts = await getAllPosts()
  return {
    paths: posts.map(p => ({ params: { slug: p.slug } })),
    fallback: 'blocking',
  }
}
Enter fullscreen mode Exit fullscreen mode
// AFTER: app/blog/[slug]/page.tsx (App Router)
export const revalidate = 3600

export async function generateStaticParams() {
  const posts = await getAllPosts()
  return posts.map(post => ({ slug: post.slug }))
}

export default async function BlogPost({ params }: { params: { slug: string } }) {
  const post = await getPostBySlug(params.slug) // direct call, no getStaticProps
  return <article dangerouslySetInnerHTML={{ __html: post.content }} />
}
Enter fullscreen mode Exit fullscreen mode

Notice: getStaticProps and getStaticPaths collapse into the component itself and generateStaticParams. The page is cleaner and the data co-location is obvious.

Step 3: Migrate Layouts

Pages Router layouts use _app.tsx and _document.tsx. App Router uses nested layout.tsx files:

// BEFORE: pages/_app.tsx
function MyApp({ Component, pageProps }) {
  return (
    <ThemeProvider>
      <AuthProvider>
        <Component {...pageProps} />
      </AuthProvider>
    </ThemeProvider>
  )
}
Enter fullscreen mode Exit fullscreen mode
// AFTER: app/layout.tsx
// ThemeProvider and AuthProvider must be Client Components
// But they can wrap server-rendered children

import { ThemeProvider } from '@/providers/ThemeProvider'
import { AuthProvider } from '@/providers/AuthProvider'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <ThemeProvider>
          <AuthProvider>
            {children}
          </AuthProvider>
        </ThemeProvider>
      </body>
    </html>
  )
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Move API Routes to Server Actions (Where Appropriate)

Not all API routes need to become Server Actions — routes consumed by external clients, mobile apps, or third parties should stay as Route Handlers. But internal mutation endpoints are good candidates:

// BEFORE: pages/api/posts/create.ts
export default async function handler(req, res) {
  if (req.method !== 'POST') return res.status(405).end()
  const { title, content } = req.body
  const post = await db.post.create({ data: { title, content } })
  res.json(post)
}

// AFTER: app/actions.ts
'use server'
export async function createPost(formData: FormData) {
  const title = formData.get('title') as string
  const content = formData.get('content') as string
  const post = await db.post.create({ data: { title, content } })
  revalidatePath('/blog')
  return post
}
Enter fullscreen mode Exit fullscreen mode

Migration Checklist

  • getServerSideProps → async server component body with cache: 'no-store' if needed
  • getStaticProps + revalidate → async server component body with export const revalidate = N
  • getStaticPathsgenerateStaticParams()
  • useRouter() for navigation → useRouter() from 'next/navigation' (different import)
  • router.queryparams and searchParams as props
  • next/headexport const metadata in layout.tsx or page.tsx
  • _app.tsx providers → Client Component providers in app/layout.tsx

Conclusion: The Shift That Matters

After building production applications with React Server Components, the biggest realization is that the paradigm shift isn't really about APIs — it's about where computation belongs.

SSR always felt like a bolt-on. You wrote React components that were designed for the client, then you also ran them on the server. RSC inverts this: you write server-first components, then you opt specific subtrees into client interactivity.

The result is applications that are faster by default, easier to reason about, and cheaper to run — your database queries happen in your components, not in a chain of API routes. Your JavaScript bundle only includes code that actually needs to run in the browser.

The teams that get the most out of RSC are the ones who internalize the mental model and apply it consistently. Default to server. Add 'use client' deliberately. Use Suspense boundaries liberally — they cost nothing and enable streaming. Validate Server Actions like public APIs because they are.

React Server Components are not a temporary trend or an experimental curiosity. They are the architecture that React has been building toward for years. The future of React is largely server-first, and App Router is that future in production today.


Wilson Xu is a frontend engineer with production experience building RSC-powered applications using Next.js App Router. Code examples reflect real patterns from e-commerce, SaaS dashboards, and content platforms.

Top comments (0)