Next.js

Choose a server or client component by following the data

Kamruz
Kamruz
Oct 14, 2026
5 min read
Choose a server or client component by following the data

A product detail page needs its description, current availability, and a quantity selector. An AI assistant adds use client to the whole page because the selector needs state. The selector works, but a database import now enters the wrong module graph. A later attempt to repair the build moves every data request into an effect, leaving the initial page empty. The original requirement was a small interactive control; the architectural change was much larger.

This example assumes the Next.js 15 App Router, including a project pinned to 15.1.11. Examples below use ordinary server and client boundaries available in that version. Check your installed framework and React versions before adopting examples from newer documentation, especially experimental caching features.

Draw the data journey first

Write down where each value originates and who may see it. The product description is public database content. The inventory query may use a private database connection string. The selected quantity belongs to the current browser interaction. A supplier cost field is private even if the same database record contains the public price. These differences determine the boundary more accurately than whether the page looks interactive.

Next, list operations rather than components: read product, select fields, render description, change quantity, submit cart update. Only quantity changes require browser state in this journey. Cart submission crosses another boundary and needs server validation. Passing a numeric price to a control does not make the browser authoritative over the eventual purchase price. The server must read or validate its own current business values.

A useful review artifact is a short flow: database record to public product view to server-rendered article to quantity control. Mark every arrow that crosses into the browser. That crossing includes serialized props, not just visible text. A hidden field can still be exposed.

Diagnose the import boundary

Start at the file containing use client and trace its imports. Do not search only for direct database imports in the page. A formatter might import a general utility barrel that reexports a repository, and the repository might initialize a driver. The actual dependency graph matters. An assistant can enumerate these imports, but ask it to name file paths and distinguish an observed import from an inferred runtime dependency.

Client Components can participate in initial server rendering. The directive is not a promise that the file only ever executes in a browser. It identifies the client module boundary and permits interactive React features. Consequently, browser globals accessed during render can still fail when initial HTML is produced. A check for window may avoid a reference error while introducing a different first-render mismatch.

Use server-only in private data modules to make accidental client imports fail clearly. This is a guard against a mistaken dependency, not a replacement for authorization or careful field selection. Review every exported function's result separately.

Make the smallest safe split

Keep the page responsible for reading and presenting public data. Extract the stateful quantity selector into a small file with the client directive. Pass it only the product identifier and safe interaction settings, such as a maximum quantity computed for display. Do not pass a complete ORM document simply because it is convenient.

An illustrative server page awaits a repository function, handles a missing product, and renders a public view. The client control receives plain values. The repository name is an application abstraction, not a built-in Next.js API.

// app/products/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { readPublicProduct } from '@/lib/products'
import QuantityPicker from './quantity-picker'

export default async function Page({ params }: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const product = await readPublicProduct(slug)
if (!product) notFound()
return <main>
<h1>{product.title}</h1>
<p>{product.description}</p>
<QuantityPicker productId={product.id} />
</main>
}

The control can import React state without importing the repository. A separate server endpoint or action processes cart changes, authenticates when needed, and rejects invalid quantities. This preserves a small client dependency surface while keeping meaningful data available in the initial response.

Define a public view deliberately

The boundary is easier to maintain when the repository returns an explicit object: identifier, title, description, display price. Convert database-specific identifiers into a supported public representation. Do not assume every library object can become a React prop. React supports defined serialization types, while ORM instances may carry prototypes, methods, and fields that do not belong in a browser response.

Constructing a public view also documents the product contract. If a new internal field appears in the database, it should not automatically appear in serialized props. The same projection should serve the page's metadata when appropriate, so the title and visible article describe the same resource. See metadata from the content contract for that related boundary.

Avoid duplicating all business logic in the picker. Client-side limits provide immediate feedback, but the server independently enforces them. Availability can change between rendering and submission, so the response should explain an out-of-stock result without pretending the user's earlier view guaranteed a reservation.

Verify both environments

Run a production build and open a product URL directly, rather than only reaching it through client navigation. Inspect the response HTML for the description. Check the browser's network responses and serialized data for supplier cost, credentials, and other private fields. A secure-looking screenshot is insufficient evidence. Then operate the picker with a keyboard and submit invalid quantities directly to the server boundary.

Add a missing-product case and a repository failure case. Missing content should take the intended missing-resource path; a database outage should not silently become a product that supposedly does not exist. Keep these outcomes separate when extracting the data access function. Test the client control with safe props independently and test the repository's public view against a record containing extra private fields.

For an AI review, provide the page, control, and repository interfaces together. Ask which values cross the boundary and which imports enter the browser graph. Verify its claims against the files and compiled application. The useful output is a concrete dependency or field finding, not an assurance that server components are generally secure.

Choose larger client boundaries only with a reason

An editor with continuous local drafts, drag operations, and offline behavior may justify a larger client subtree. A mostly static product page usually does not. Moving a directive lower is not an absolute performance rule; it is a way to align dependencies with responsibilities. Measure the actual bundle and user interaction before adding more component fragmentation.

Server rendering also introduces dependency on backend availability during rendering unless an intentional caching strategy applies. Decide whether a public product can be briefly stale and how updates invalidate that result. That is a separate design choice described in published content freshness. Keeping it explicit prevents a boundary fix from quietly changing freshness guarantees.

Sources

About the Author

Kamruz

Kamruz

Full Stack Developer

A passionate developer who loves creating modern web applications and sharing knowledge with the community.

More About Me

Related Articles