The Build Ledger Search articles
Back to articles

How to Design React Server Component Boundaries That Stay Maintainable

A practical framework for deciding what belongs on the server, what belongs in the browser, and where state should cross the boundary.

React Server Components are easiest to reason about when the boundary follows ownership. The problem is that real product pages rarely arrive as clean examples. A page starts as a table, then adds filters, saved views, permission checks, bulk actions, and a modal that calls three endpoints. Six weeks later nobody knows why the whole route became a client component.

The example below is based on a common account settings page. The team wanted to show billing status, notification preferences, workspace members, and a few permission-gated actions. The first version worked, but the client bundle kept growing because the page component imported data mappers, permission helpers, and UI state in one file.

The messy version

The route began with a shape like this:

"use client";

export default function AccountSettingsPage() {
  const [tab, setTab] = useState("billing");
  const [members, setMembers] = useState([]);
  const [plan, setPlan] = useState(null);

  useEffect(() => {
    fetch("/api/account/settings")
      .then(res => res.json())
      .then(data => {
        setMembers(data.members);
        setPlan(data.plan);
      });
  }, []);

  return (
    <SettingsShell>
      <Tabs value={tab} onChange={setTab} />
      <BillingSummary plan={plan} />
      <MemberTable members={members} />
      <NotificationToggle />
    </SettingsShell>
  );
}

It was easy to build and hard to maintain. The browser fetched data that was already available on the server. Permission logic moved into API responses because the client component needed to decide which buttons to show. Loading states appeared twice: once for the page and once for individual panels.

Split by data ownership first

The first fix was not a new abstraction. It was moving durable account data back to the server:

// page.tsx
import { loadAccountSettings } from "./load-account-settings";
import { SettingsView } from "./settings-view";

export default async function Page() {
  const viewModel = await loadAccountSettings();
  return <SettingsView data={viewModel} />;
}

The loader returns a view model, not raw database rows:

export async function loadAccountSettings() {
  const account = await requireAccount();
  const [plan, members, permissions] = await Promise.all([
    getBillingPlan(account.id),
    listWorkspaceMembers(account.id),
    getCurrentUserPermissions(account.id),
  ]);

  return {
    planLabel: plan.displayName,
    renewalDate: formatDate(plan.renewalDate),
    members: members.map(member => ({
      id: member.id,
      name: member.name,
      role: member.role,
    })),
    canInviteMembers: permissions.includes("members.invite"),
  };
}

This keeps secrets, account lookup, and permission checks on the server. The client receives plain data shaped for rendering.

Make interaction islands small

The view can stay mostly server-rendered:

export function SettingsView({ data }) {
  return (
    <SettingsShell>
      <BillingSummary label={data.planLabel} renewalDate={data.renewalDate} />
      <MemberTable members={data.members} canInvite={data.canInviteMembers} />
      <NotificationToggleClient initialValue={data.notifications.email} />
    </SettingsShell>
  );
}

Only the toggle needs browser state. That file is clearly marked:

"use client";

export function NotificationToggleClient({ initialValue }) {
  const [enabled, setEnabled] = useState(initialValue);

  async function save(nextValue) {
    setEnabled(nextValue);
    await updateNotificationPreference({ email: nextValue });
  }

  return <Switch checked={enabled} onCheckedChange={save} />;
}

The client component owns interaction state. It does not own account loading, permission checks, or response normalization.

Clipboard_Screenshot_1781144088.png

Watch for bundle leaks

After the split, inspect imports. A client file importing @/server/auth, database mappers, billing SDKs, or environment helpers is a boundary leak. The fix is not to silence the error; it is to pass a smaller prop from the server.

Also check the bundle diff. Moving one child component across the boundary can accidentally pull a charting library, date library, or admin SDK into the browser. The visual page may look identical while the route becomes heavier.

Use commands for persistence

When a client island needs to persist a change, send the smallest command back:

await updateNotificationPreference({ email: nextValue });

Do not send the whole account settings object. The server action or endpoint should re-check identity, validate the command, write the change, and return the new state or a stable error code. The browser should not become the source of truth for permission-sensitive data.

Review questions before merging

For this account settings route, the review checklist became:

  • Does the page render useful account data before JavaScript?
  • Are permission decisions made on the server?
  • Do client components import only UI helpers and command functions?
  • Can a new billing field be added without moving the whole route to the client?
  • Did the bundle diff stay within the expected range?

These questions are more useful than arguing whether a page is “server” or “client.” Real routes are mixed. Maintainability comes from making each side own the right work.