A product page needs to read product data, show availability, and let a visitor save an item. Does the whole page need to be a Client Component because one button has state? No. But moving the page to the server does not automatically make it fast, fresh, or secure either.

React Server Components (RSC) are useful when a framework can render data-dependent UI outside the browser while keeping interactive code in smaller client boundaries. The engineering decision is where each dependency runs and which data crosses the boundary, not whether server rendering is inherently better than client rendering.

Scope: This guide uses the Next.js 15 App Router model. Its caching details are specific to that release line; Next.js 16 also has an opt-in Cache Components model. The example is a small, illustrative catalog fixture, not a production deployment or a measured performance experiment.

First, separate RSC, SSR, and hydration

These terms describe different jobs.

MechanismWhat it doesWhat it does not promise
Server ComponentExecutes its component logic outside the browser and produces an RSC payload describing UI and client boundariesIt does not hydrate, hold browser state, or necessarily run on every request
Server-side rendering (SSR)Produces initial HTML for the browser; in Next.js it can include the initial output of Client ComponentsIt does not eliminate the JavaScript needed to hydrate interactive components
Client ComponentProvides browser-side state, events, effects, and browser API accessThe 'use client' directive does not mean its initial HTML cannot be prerendered on the server

For the initial request, Next.js can send HTML for a useful first view, along with an RSC payload and JavaScript for the client portions. On subsequent client-side navigation, the framework can use a new RSC payload to update the UI without a full document load. The original Server Component function is not downloaded for execution in the browser. These are separate outputs and costs, not a single package called "server HTML."

React's Server Components reference explicitly allows server work at build time or per request. The Next.js rendering explanation describes how the payload, prerendered HTML, and hydration fit together.

Two naming traps matter during reviews. There is no 'use server' directive for declaring a Server Component: in the App Router, pages and layouts are Server Components by default unless a client boundary changes the import graph. 'use server' marks callable Server Functions, which have a different purpose. Also, the 'use client' directive affects the module's transitive imports, not every server-rendered children element passed through it. React documents that distinction in its client boundary reference.

Trace a product page across the boundary

Consider this hypothetical tree:

  • ProductPage runs on the server and obtains the public product record.
  • Inventory runs on the server, potentially waiting for a separate stock lookup.
  • SaveButton runs in the browser because it changes local button state.

The fixture below uses a literal record instead of inventing a database or a remote API. Replace this source with your own authenticated or public data layer as appropriate. The presence of async makes the functions compatible with asynchronous data sources, but the in-memory fixture is too fast to demonstrate streaming latency.

// lib/products.ts
import 'server-only'

type ProductRecord = {
  id: string
  name: string
  description: string
  inventory: number
  internalCostCents: number
}

const catalog: Record<string, ProductRecord> = {
  'desk-01': {
    id: 'desk-01',
    name: 'Compact desk',
    description: 'A small desk for limited floor space.',
    inventory: 4,
    internalCostCents: 12000,
  },
}

export async function getProduct(id: string): Promise<ProductRecord | null> {
  return catalog[id] ?? null
}

export async function getInventory(id: string): Promise<number | null> {
  return catalog[id]?.inventory ?? null
}

The server-only import tells Next.js to reject accidental imports of this module from the client graph. It protects the module boundary, not any confidential value you deliberately render or pass as a prop.

// app/products/[id]/page.tsx
import { Suspense } from 'react'
import { notFound } from 'next/navigation'
import { getInventory, getProduct } from '@/lib/products'
import SaveButton from './SaveButton'

type Props = { params: Promise<{ id: string }> }

export default async function ProductPage({ params }: Props) {
  const { id } = await params
  const product = await getProduct(id)
  if (!product) notFound()

  return (
    <main>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <SaveButton productId={product.id} />
      <Suspense fallback={<p role="status">Checking stock...</p>}>
        <Inventory productId={product.id} />
      </Suspense>
    </main>
  )
}

async function Inventory({ productId }: { productId: string }) {
  const count = await getInventory(productId)
  if (count === null) return null

  return <p>{count > 0 ? 'In stock' : 'Out of stock'}</p>
}

Next.js 15 models dynamic route params as a promise, which is why the page awaits it. For a missing product, notFound() selects the not-found path instead of returning a misleading success page.

// app/products/[id]/SaveButton.tsx
'use client'

import { useState } from 'react'

export default function SaveButton({ productId }: { productId: string }) {
  const [saved, setSaved] = useState(false)

  return (
    <button
      type="button"
      aria-pressed={saved}
      onClick={() => setSaved(value => !value)}
      data-product-id={productId}
    >
      {saved ? 'Saved in this view' : 'Save in this view'}
    </button>
  )
}

This button changes local view state only. It does not persist a wishlist and must not be presented as doing so. Persisting a favorite would require a separate mutation with server-side identity, input validation, and object-level authorization. For that distinction, see why Server Action authentication is not enough.

Only the fields used to render the page and the string productId passed to SaveButton need to cross into browser-visible output. The fixture contains internalCostCents to make the review question concrete: it stays server-side only because we never render or pass it. Importing server-only does not automatically redact a value placed in JSX or serialized props.

Streaming still has a critical path

The Suspense boundary around Inventory allows that part of the UI to wait independently. It does not change the fact that ProductPage awaits getProduct before it can return its tree. To show a route-level fallback while that initial lookup waits, add a segment loading file:

// app/products/[id]/loading.tsx
export default function Loading() {
  return <p role="status">Loading product...</p>
}

In this fixture, the lookup is immediate. In an application with a slower data source, verify when the loading UI and streamed result actually appear using a production build and a throttled connection.

RSC also cannot eliminate an inherently dependent chain. If you must fetch an account before you know which orders to query, there is still a server-side dependency. For independent data, start both operations before awaiting their results, or use Promise.all. Move nonessential waiting behind a useful Suspense boundary. Measure the resulting response and user-visible timing, because moving a waterfall from browser to server is not the same as removing it.

Keep unexpected failures separate from absence: a missing item can return notFound(); a database outage should reach an appropriate error.tsx boundary and be logged without exposing internal details to the visitor. A fallback is not a replacement for an error path.

Audit the client boundary, not just the component names

The most expensive migration mistake is placing 'use client' on a broad page or layout to make one small control work. Every module imported by that client entry point becomes part of the client module graph, subject to bundler optimization. That can include formatting or visualization libraries that never needed browser APIs.

A useful review asks four questions:

  1. Does the component require state, effects, event handlers, or browser APIs? If yes, it needs a client boundary.
  2. Is the code only formatting static data or accessing server-only sources? Keep it outside the client graph where feasible.
  3. Can the client component receive a small, explicitly selected set of props instead of a full database record?
  4. Is a Server Component passed as children to a Client Component? That composition is valid; visual nesting does not automatically turn the Server Component's source into client code.

Don't move a highly interactive editor, drag-and-drop canvas, or live collaborative interface to the server simply to avoid JavaScript. Those interfaces still need client logic and often benefit from local or real-time client-side data management. RSC changes the division of responsibility; it does not prohibit browser applications.

For a companion diagnosis of unnecessarily broad client boundaries, see common React and Next.js mistakes.

Data freshness is a separate architectural decision

A component running on the server does not imply that its data is fresh on every view. In Next.js 15, ordinary server fetch requests are not stored in the persistent Data Cache by default, but explicitly cached requests, statically rendered routes, and the client-side Router Cache can produce different behavior. The Next.js 15 caching guide distinguishes these layers.

The example above reads an in-memory object through getProduct, not fetch. Replacing it with a database client does not automatically make each page request re-read the database. In Next.js 15, check whether the route is statically rendered or rendered for each request. If this page must be rendered per request, one explicit option is export const dynamic = 'force-dynamic' at the top level of app/products/[id]/page.tsx. That is a route-wide decision, not a default to copy everywhere. It opts the route out of the Full Route Cache and the fetch Data Cache, but browser-side Router Cache behavior and any database-level caching still need separate review. For a mostly stable catalog, static rendering with deliberate revalidation may be preferable. See the Next.js 15 route segment options.

SituationQuestion to answer before release
Public catalog informationIs stale content acceptable, and what event or interval invalidates the cache?
User-specific account dataIs the response scoped to a verified session, and can it ever be shared across users?
Frequently changing inventoryHow quickly must the UI reflect stock changes, and is polling or push-based updating necessary?
A mutation followed by navigationWill the relevant route/data caches be invalidated, or does the user need an explicit refresh?

Request memoization, persistent data caching, full-route caching, and browser-side navigation caching are not synonyms. A repeat read deduplicated within one render says nothing about what another user will receive. Likewise, router.refresh() requests a fresh server render for the current route but does not by itself invalidate every server-side cached data entry.

Do not treat a public product fixture as a safe template for private account information. The permission check must happen in the server data layer against a verified caller; hiding a control in a Client Component is not authorization. A Server Component can leak private data through its rendered output or props even when its source code never reaches the browser.

The transport also deserves maintenance. React disclosed critical RSC/Server Function vulnerabilities and later fixes in 2025 and 2026. Before deploying an RSC framework, check its supported, patched release line rather than relying on the architectural boundary for safety. The React security advisory explains why earlier patches were insufficient.

A release check that can disprove your assumptions

Run these checks against your own production-mode build, not just a development server:

  1. Inventory the modules: Look for broad client entries and confirm that server data-access and server-only parsing packages are not included in client chunks.
  2. Inspect what crosses the wire: In DevTools Network, compare the initial document, JavaScript chunks, and RSC navigation requests. Look for values you did not intend to expose. Server Component source being absent does not mean the serialized output is confidential.
  3. Exercise slow and failed data: Add controlled latency in a staging data source. Confirm the route loading state, the stock Suspense boundary, missing-product behavior, and error recovery. The fixture above contains no such artificial delay.
  4. Test identity boundaries: With two test accounts, attempt direct URL access, navigation back/forward, and mutation of another account's records. Verify server-side authorization and cache scoping after logout.
  5. Compare user outcomes: Use the same route, device class, network profile, and production configuration when comparing shipped JavaScript, response timing, LCP, INP, and user-visible loading. Record repeated runs and variability rather than claiming improvements from architecture alone.

This guide did not run a Next.js build, a browser trace, or a production benchmark. Its code illustrates the component boundary and a safe explicit-props pattern. Adapt and verify it with a real data layer before using it for a production feature. If hydration fails in that process, this hydration mismatch debugging guide addresses a different, related failure class.

When RSC is the wrong investment

RSC earns its complexity when you have meaningful server-side data or rendering work to keep out of the browser, a supported framework, and a route with a clear server/client split. It may not earn a migration when an existing client-rendered application is fast enough, most interactions require rich browser state, or hosting and operational constraints make the server round trips more expensive than the problem being solved.

For the skeptical engineer, the decision is testable: identify the data boundary, keep interactivity local, inspect the actual payloads and caches, and compare the production result. If that review does not reveal a meaningful benefit, there is no requirement to move the application to RSC.