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.

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.