Choose boundaries by responsibility, not by file location
A Server Component is a good default for a route, layout, or feature that reads trusted data and turns it into UI. A Client Component is warranted when the browser must hold state, react to events, access browser APIs, or run an interactive library. The useful question is not “which component is newer?” It is “where can this work be done with the least data exposure and JavaScript?”
Keep on the server
Authentication checks, database queries, provider tokens, permission decisions, and expensive data shaping.
Keep in the browser
Click handlers, local form state, drag-and-drop, media controls, and browser-only integrations.
Pass a narrow contract
Send only the fields an interactive child needs; do not serialize a whole record “just in case.”
Start high in the tree with a server-rendered route. Then mark only the smallest interactive leaf with "use client". That keeps the client boundary from pulling unrelated components, helpers, and dependencies into the browser bundle.
Make the server the owner of protected data
Reading data from a Server Component is not merely a performance technique. It gives the server one place to authenticate the request, apply tenant or record-level rules, and select a safe response shape. Never treat the absence of a button in the UI as an authorization decision; a user can still call a route or mutation directly.
// app/projects/[projectId]/page.tsx
import { notFound } from "next/navigation";
import { getSessionUser, getProjectForMember } from "@/lib/projects";
import ProjectToolbar from "./ProjectToolbar";
export default async function ProjectPage({ params }) {
const user = await getSessionUser();
const project = await getProjectForMember({
projectId: params.projectId,
userId: user.id,
});
if (!project) notFound();
return (
<>
<h1>{${project.name}}</h1>
<ProjectToolbar projectId={${project.id}} canArchive={${project.canArchive}} />
</>
);
}Notice that the client toolbar receives an ID and one explicit capability, rather than an unfiltered project object or a session token. The mutation must still repeat the authorization check—capabilities in props improve UX, but they are not security controls. For the full credential and environment model, see our Next.js secret-management guide.
Build small client islands with stable inputs
A client island should own a focused interaction: a filter, a date picker, an editable status, or a rich-text composer. It should receive simple serializable values and report intent upward through a server action or route handler. Avoid turning an entire page into a client component because one control needs a click handler.
// app/projects/[projectId]/ProjectToolbar.tsx
"use client";
import { useTransition } from "react";
import { archiveProject } from "./actions";
export default function ProjectToolbar({ projectId, canArchive }) {
const [isPending, startTransition] = useTransition();
if (!canArchive) return null;
return (
<button disabled={isPending} onClick={() => startTransition(() => archiveProject(projectId))}>
{isPending ? "Archiving…" : "Archive project"}
</button>
);
}This approach makes the dependency direction obvious: the page supplies trusted facts, the toolbar owns temporary interaction state, and the mutation owns the write. It also makes components easier to test because their props are explicit and small.
Validate and authorize every mutation at the write boundary
Server Actions and route handlers are entry points, not internal shortcuts. Validate input, identify the caller from the trusted session, verify authorization against the current record, perform the write transactionally, and then refresh only the affected views. Do not accept an organization ID, role, price, or ownership flag from the client as proof of permission.
// app/projects/[projectId]/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { requireUser, archiveProjectForMember } from "@/lib/projects";
export async function archiveProject(projectId: string) {
const user = await requireUser();
const archived = await archiveProjectForMember({ projectId, userId: user.id });
if (!archived) throw new Error("Project not found or not permitted");
revalidatePath("/projects/" + projectId);
revalidatePath("/projects");
}Add CSRF protection where your authentication model requires it, rate-limit costly or abuse-prone operations, and return errors that users can act on without exposing internals. Our Node.js API rate-limiting guide explains how to make those policies fair in production.
Design loading and failure states at the same boundary
A clean component boundary also creates a clean recovery boundary. Stream slow, independent regions with a loading state instead of holding back the whole route. Place error boundaries around meaningful feature areas so a broken recommendation, chart, or integration does not blank the account page. A fallback should say what failed, preserve unaffected work, and offer a safe retry when one exists.
Test these states intentionally: a slow database, a denied permission, an expired session, a network retry, and a failed write. Pair the UI behavior with useful error reporting. The React error-boundary recovery guide covers practical fallback and monitoring patterns.
React Server Component production checklist
✓ Routes and layouts default to Server Components where possible
✓ Client components are small islands with explicit serialized props
✓ Secrets, sessions, and permission checks remain on the server
✓ Reads select only the fields the UI actually needs
✓ Every write validates input and re-checks authorization
✓ Revalidation targets affected paths or tags after a successful write
✓ Loading, empty, denied, and failed states are tested deliberately
✓ Client bundle growth is checked when adding interactive dependencies
Build a React architecture that stays easy to change
Endurance Softwares helps teams build and modernize React and Next.js products with secure data access, focused UI interactions, dependable APIs, and practical delivery practices.