Client and Server Components
MiHCM UI components are browser-capable React components. In a Next.js App Router app, treat the package entrypoints as client boundaries that can be rendered from Server Components.
That gives you the normal Next.js split:
- Server Components fetch data, read the session, choose layout, and render static component trees.
- Client Components own browser state, event handlers, controlled values, keyboard interaction, drag/drop, charts, and menu behavior.
- Props that cross from a Server Component into a Client Component must be serializable.
Quick rule
Use a component directly in a Server Component when the props are static:
// app/settings/page.tsx -- Server Component
import { Button } from '@mihcm/ui/Button';
import { Card, CardContent, CardHeader, CardTitle } from '@mihcm/ui/Card';
export default async function SettingsPage() {
const user = await getCurrentUser();
return (
<Card>
<CardHeader>
<CardTitle>{user.name}</CardTitle>
</CardHeader>
<CardContent>
<Button variant="secondary">View profile</Button>
</CardContent>
</Card>
);
}Create a client wrapper when the component needs handlers, local state, browser APIs, or non-serializable config:
// app/settings/DeleteUserButton.tsx -- Client Component
'use client';
import { useState } from 'react';
import { Button } from '@mihcm/ui/Button';
export function DeleteUserButton({ userId }: { userId: string }) {
const [pending, setPending] = useState(false);
return (
<Button
variant="destructive"
loading={pending}
onClick={async () => {
setPending(true);
await fetch(`/api/users/${userId}`, { method: 'DELETE' });
setPending(false);
}}
>
Delete user
</Button>
);
}// app/settings/page.tsx -- Server Component
import { DeleteUserButton } from './DeleteUserButton';
export default async function SettingsPage() {
const user = await getCurrentUser();
return <DeleteUserButton userId={user.id} />;
}Safe Server Component usage
These patterns are safe in Server Components:
import { Badge } from '@mihcm/ui/Badge';
import { Text } from '@mihcm/ui/Text';
export default async function Page() {
const status = await getStatus();
return (
<section className="space-y-3">
<Text size="2xl" weight="semibold">Payroll run</Text>
<Badge tone={status === 'ready' ? 'success' : 'warning'}>
{status}
</Badge>
</section>
);
}Use this for:
| Pattern | Server-safe? | Notes |
|---|---|---|
Static visual primitives (Text, Card, Badge, Alert, Skeleton) | Yes | Props are strings, booleans, variants, and JSX children. |
Static form markup (Input, Label, Textarea) | Yes | Add a client wrapper once you need controlled state or validation events. |
Overlay shell (Dialog, Popover, Sheet, Drawer) | Sometimes | Static composition can be declared on the server; open state and handlers belong in a wrapper. |
Navigation shell (NavigationMenu, Sidebar, Tabs) | Sometimes | Static links are fine; dynamic active state, routing hooks, and menu data with icon constructors belong in a wrapper. |
Data-heavy components (DataTable, Chart, Calendar, DatePicker) | Usually wrapper | Column renderers, formatters, chart callbacks, dates, and interactive state are not clean RSC payloads. |
What cannot cross the boundary
Do not pass these from a Server Component into a Client Component:
// Do not pass these across the RSC boundary.
onClick={() => save()}
formatter={(value) => value.toLocaleString()}
date={new Date()}
range={new Map()}
icon={WalletCards}
columns={[{ cell: ({ row }) => <Actions row={row} /> }]}Use serializable values instead:
type PayrollSummary = {
id: string;
employeeCount: number;
status: 'draft' | 'ready' | 'paid';
payDate: string; // ISO string, not Date
};Then rebuild handlers, icons, column renderers, and formatters inside a client wrapper.
Complex NavigationMenu pattern
For a large mega menu, keep the whole menu implementation in a client component. This avoids passing icon component constructors and nested interactive menu content across the server/client boundary.
// app/components/ProductNavigation.tsx
'use client';
import { WalletCards, ShieldCheck } from '@mihcm/icons';
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
} from '@mihcm/ui/NavigationMenu';
const productLinks = [
{
title: 'Payroll engine',
description: 'Run compliant salary and statutory workflows.',
href: '/products/payroll',
icon: WalletCards,
},
{
title: 'Benefits hub',
description: 'Coordinate claims, eligibility, and provider flows.',
href: '/products/benefits',
icon: ShieldCheck,
},
];
export function ProductNavigation() {
return (
<NavigationMenu className="w-full max-w-screen-lg justify-start">
<NavigationMenuList className="justify-start">
<NavigationMenuItem>
<NavigationMenuTrigger>Products</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="grid w-screen max-w-screen-lg gap-2 p-4 md:grid-cols-2">
{productLinks.map((link) => {
const Icon = link.icon;
return (
<li key={link.href}>
<NavigationMenuLink asChild>
<a
href={link.href}
className="flex min-h-20 gap-3 rounded-md p-3 hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="inline-flex h-9 w-9 items-center justify-center rounded-md border border-border bg-card text-primary">
<Icon className="h-4 w-4" aria-hidden />
</span>
<span>
<span className="block text-body-sm font-semibold text-foreground">
{link.title}
</span>
<span className="block text-body-sm text-muted-foreground">
{link.description}
</span>
</span>
</a>
</NavigationMenuLink>
</li>
);
})}
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
);
}// app/layout.tsx -- Server Component
import { ProductNavigation } from './components/ProductNavigation';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<ProductNavigation />
{children}
</body>
</html>
);
}Dialog and form pattern
Use Server Components for the page and data, then put the controlled form inside a client file:
// app/people/EditPersonDialog.tsx
'use client';
import { useState } from 'react';
import { Button } from '@mihcm/ui/Button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@mihcm/ui/Dialog';
import { Input } from '@mihcm/ui/Input';
export function EditPersonDialog({ name }: { name: string }) {
const [value, setValue] = useState(name);
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">Edit</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit person</DialogTitle>
</DialogHeader>
<Input value={value} onChange={(event) => setValue(event.target.value)} />
</DialogContent>
</Dialog>
);
}DataTable and Chart pattern
Keep server data serializable. Define column renderers, chart configs, formatters, and tooltip callbacks in a client component.
// app/analytics/AnalyticsPanel.tsx
'use client';
import { ChartContainer } from '@mihcm/ui/Chart';
interface Point {
month: string;
payroll: number;
}
export function AnalyticsPanel({ data }: { data: Point[] }) {
const config = {
payroll: {
label: 'Payroll',
color: 'var(--color-primary)',
},
};
return (
<ChartContainer config={config} className="h-80">
{/* Recharts primitives live here. */}
</ChartContainer>
);
}Checklist
- Import by subpath:
@mihcm/ui/Button, not the root barrel. - Server Component first for data and layout.
- Client wrapper for
useState,useEffect, browser APIs, event handlers, routing hooks, drag/drop, charts, tables, and menus with icon component maps. - Pass IDs, strings, numbers, booleans, arrays, plain objects, and ISO date strings across the boundary.
- Recreate functions, renderers, icons, and class callback logic inside the client file.
- Keep token styling semantic:
bg-card,bg-muted,text-foreground,text-muted-foreground,text-primary,border-border,ring-ring.