15 KiB
Component Development Guidelines
This document covers component development patterns including Server vs Client components, semantic HTML, and UI best practices.
Server vs Client Components
Default to Server Components
Next.js App Router defaults to Server Components. Use them for:
- Data fetching
- Accessing backend resources directly
- Keeping sensitive data on the server
- Reducing client-side JavaScript
// app/(app)/dashboard/page.tsx (Server Component)
import { DashboardStats } from '@/modules/dashboard/components';
export default async function DashboardPage() {
// Can fetch data directly
const stats = await fetchDashboardStats();
return (
<main>
<h1>Dashboard</h1>
<DashboardStats data={stats} />
</main>
);
}
When to Use Client Components
Add 'use client' directive only when you need:
- Event handlers (onClick, onChange, etc.)
- useState, useEffect, or other React hooks
- Browser-only APIs (localStorage, window)
- Class components with lifecycle methods
'use client';
import { useState } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}
Composition Pattern
Keep Server Components at the top, push Client Components down:
// Server Component (page.tsx)
import { ProductList } from './ProductList';
import { FilterSidebar } from './FilterSidebar'; // Client
export default async function ProductsPage() {
const products = await fetchProducts();
return (
<div className="flex">
<FilterSidebar /> {/* Client component for interactivity */}
<ProductList products={products} /> {/* Can be server or client */}
</div>
);
}
Passing Server Data to Client Components
// Server Component
export default async function Page() {
const initialData = await fetchData();
return <InteractiveWidget initialData={initialData} />;
}
// Client Component
'use client';
export function InteractiveWidget({ initialData }: { initialData: Data }) {
const [data, setData] = useState(initialData);
// Interactive logic...
}
Semantic HTML
Kumo UI Convention
Use components/ui/* as the project-owned adapter layer for Cloudflare Kumo components.
Feature modules and route files should import Button, Input, Select, Textarea, Table,
Checkbox, Badge, Card, and Dialog from @/components/ui/... instead of importing
@cloudflare/kumo directly.
// Good: project adapter keeps Kumo API differences local
import { Select } from "@/components/ui/select";
import { Table } from "@/components/ui/table";
// Avoid: feature code should not hand-style native controls
<select name="roleId" />
<table />
Visible form controls and data tables should use the Kumo-backed adapters. Native hidden inputs are acceptable only inside adapters when needed to preserve FormData semantics.
Select Adapter Positioning Contract
components/ui/select.tsx is intentionally project-owned instead of directly wrapping
@cloudflare/kumo/components/select. Kumo Select 2.6 can inherit Base UI
alignItemWithTrigger=true, which may render data-side="none" and overlap the trigger in
production.
The project Select adapter must:
- Preserve the local API:
options,value,defaultValue,name,placeholder,disabled,required,aria-*, andonValueChange. - Render a hidden form value only inside the adapter when
nameis provided. - Use portal/fixed positioning for the popup and keep a positive gap between trigger and
panel. Browser validation should assert
verticalOverlap === 0on desktop and mobile. - Keep keyboard behavior for ArrowUp/ArrowDown, Home/End, Enter/Space, Escape, and Tab.
// Good: feature code stays on the project adapter.
<Select name="organizationId" options={organizationOptions} />
// Avoid: this can reintroduce production overlay regressions.
import { Select } from "@cloudflare/kumo/components/select";
Dialog Adapter Centering Contract
components/ui/dialog.tsx may use Kumo Dialog primitives for accessibility, but the
project adapter owns panel geometry. Kumo Dialog includes fixed left-1/2 top-1/2
and translate classes, and those transforms can interact with project animations or
production CSS ordering.
The project Dialog adapter must:
- Center the panel without relying on translate transforms. Use
fixed inset-0 m-autoplus explicit width, max-height, andh-fit. - Override Kumo minimum width so mobile dialogs stay inside
100vw. - Keep
transform: noneandtranslate: 0 0on.teatea-dialog; use opacity/scale-only motion if animation is needed. - Browser validation should assert the panel center is within 2px of the viewport center on desktop and mobile.
Static Module Data Contract
Static or reserved module pages must not fabricate operational data. If a module does not have a Drizzle-backed query/API and real persisted records, render a clear empty or not-connected state instead of generated counters, fake workflows, sample records, or mock priority/status rows.
modules/shared/components/ModulePage.tsx is the shared placeholder for these modules.
Its props should stay descriptive only:
type ModulePageProps = {
title: string;
eyebrow: string;
description: string;
icon: LucideIcon;
phase: "待接入" | "二期预留" | "三期预留";
};
// Bad: static pages pretending to have live business data.
<ModulePage
title="Care"
metrics={[{ label: "Today", value: "184", detail: "142 done" }]}
workflows={["Create plan", "Dispatch task"]}
/>
// Good: no fabricated records until a real data source exists.
<ModulePage
title="Care"
eyebrow="Plans and inspections"
description="Pending connection to care plans, task dispatch, and execution records."
icon={ClipboardCheck}
phase="待接入"
/>
When a module becomes real, replace the placeholder with a Server Component that loads data from the server boundary and pass only persisted, permission-filtered data into client components.
App Shell Tenant and Account Menu Contract
The desktop app shell footer is the tenant/account workspace control, not only a sign-out surface.
- Show a compact organization switcher above the account card.
- Display both organization name and non-empty
slug; do not hide or fabricate missing tenant identifiers. - Switch organizations by calling
POST /api/auth/organizationand thenrouter.refresh(); do not store the active organization in localStorage. - The account card menu opens a user settings dialog for the current account. Profile edits
call
PATCH /api/account/profile. - OIDC binding UI must reflect real backend capability. If binding records/callbacks are not implemented, show an honest not-connected state instead of fake provider accounts.
- The sidebar nav selected state belongs in a small client component using
usePathname(); keep the rest ofAppShellserver-rendered.
Business Form Defaults Contract
Create forms for persisted business records must not prefill required domain fields with
assumed values. Defaults like age 75, gender male, care level self-care, status
active, or "first available role" can turn into real PostgreSQL records when a user
submits without noticing.
Use explicit empty selection states for required business choices, then validate before calling the API:
// Bad: saves fabricated domain decisions if the user only fills name/email.
const emptyInput = {
gender: "male",
age: 75,
careLevel: "self-care",
status: "active",
};
// Good: the user must provide the domain values that will be persisted.
const emptyInput = {
gender: "",
age: "",
careLevel: "",
status: "",
};
const genderOptions = [
{ value: "", label: "Select gender", disabled: true },
...realGenderOptions,
];
Edit forms may preload values that already exist on the persisted record. Create, invite, approve, and assignment forms should require an explicit selection for role, organization, status, and other authorization or workflow fields unless the default is server-owned and documented as a product rule.
Use Proper Elements
// Bad: div for everything
<div onClick={handleClick}>Click me</div>
<div>
<div>Item 1</div>
<div>Item 2</div>
</div>
// Good: semantic elements
<button onClick={handleClick}>Click me</button>
<ul>
<li>Item 1</li>
<li>Item 2</li>
</ul>
Button vs Div
Always use <button> for clickable actions:
// Bad: Non-semantic, no keyboard support, no accessibility
<div
className="cursor-pointer"
onClick={handleClick}
>
Save
</div>
// Good: Semantic, keyboard accessible, proper focus
<button
type="button"
onClick={handleClick}
className="..."
>
Save
</button>
Form Elements
// Bad: Missing labels, wrong elements
<div>
<span>Email</span>
<input type="text" />
</div>
// Good: Proper form structure
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
aria-describedby="email-error"
/>
{error && <p id="email-error" role="alert">{error}</p>}
</div>
Navigation
// Bad
<div onClick={() => router.push('/about')}>About</div>
// Good
<Link href="/about">About</Link>
// For programmatic navigation with button appearance
<Link href="/about" className="btn btn-primary">
About
</Link>
Next.js Image Component
Always Use next/image
// Bad: Raw img tag
<img src="/hero.jpg" alt="Hero" />
// Good: Optimized Image component
import Image from 'next/image';
<Image
src="/hero.jpg"
alt="Hero image"
width={1200}
height={600}
priority // For above-the-fold images
/>
Responsive Images
// Fill container
<div className="relative h-64 w-full">
<Image
src="/banner.jpg"
alt="Banner"
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, 50vw"
/>
</div>
Remote Images
Configure domains in next.config.js:
// next.config.js
module.exports = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'images.example.com',
},
],
},
};
Command Palette (cmdk)
Basic Implementation
'use client';
import { Command } from 'cmdk';
import { useState, useEffect } from 'react';
export function CommandPalette() {
const [open, setOpen] = useState(false);
// Toggle with keyboard shortcut
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((open) => !open);
}
};
document.addEventListener('keydown', down);
return () => document.removeEventListener('keydown', down);
}, []);
return (
<Command.Dialog
open={open}
onOpenChange={setOpen}
label="Global Command Menu"
>
<Command.Input placeholder="Type a command or search..." />
<Command.List>
<Command.Empty>No results found.</Command.Empty>
<Command.Group heading="Navigation">
<Command.Item onSelect={() => router.push('/dashboard')}>
Go to Dashboard
</Command.Item>
<Command.Item onSelect={() => router.push('/settings')}>
Go to Settings
</Command.Item>
</Command.Group>
<Command.Group heading="Actions">
<Command.Item onSelect={handleNewOrder}>
Create New Order
</Command.Item>
</Command.Group>
</Command.List>
</Command.Dialog>
);
}
With Search Results
export function SearchCommandPalette() {
const [search, setSearch] = useState('');
const { data: results, isLoading } = useSearch(search);
return (
<Command.Dialog open={open} onOpenChange={setOpen}>
<Command.Input
value={search}
onValueChange={setSearch}
placeholder="Search..."
/>
<Command.List>
{isLoading && <Command.Loading>Searching...</Command.Loading>}
<Command.Empty>No results found.</Command.Empty>
{results?.map((item) => (
<Command.Item
key={item.id}
value={item.title}
onSelect={() => handleSelect(item)}
>
{item.title}
</Command.Item>
))}
</Command.List>
</Command.Dialog>
);
}
Styling with Tailwind
Component Styling Pattern
// Use className for styling
export function Card({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<div
className={cn(
'rounded-lg border bg-card p-4 shadow-sm',
className
)}
>
{children}
</div>
);
}
Conditional Styles
import { cn } from '@/lib/utils';
export function Button({
variant = 'primary',
size = 'md',
className,
...props
}: ButtonProps) {
return (
<button
className={cn(
'inline-flex items-center justify-center rounded-md font-medium',
// Variants
{
'bg-primary text-primary-foreground': variant === 'primary',
'bg-secondary text-secondary-foreground': variant === 'secondary',
'border bg-transparent': variant === 'outline',
},
// Sizes
{
'h-8 px-3 text-sm': size === 'sm',
'h-10 px-4': size === 'md',
'h-12 px-6 text-lg': size === 'lg',
},
className
)}
{...props}
/>
);
}
Responsive Design
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{items.map((item) => (
<Card key={item.id}>{item.content}</Card>
))}
</div>
Accessibility
Focus Management
export function Modal({ open, onClose, children }: ModalProps) {
const closeButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (open) {
closeButtonRef.current?.focus();
}
}, [open]);
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent>
{children}
<button ref={closeButtonRef} onClick={onClose}>
Close
</button>
</DialogContent>
</Dialog>
);
}
ARIA Labels
<button
aria-label="Close dialog"
aria-expanded={isOpen}
aria-controls="dropdown-menu"
>
<CloseIcon />
</button>
<div
id="dropdown-menu"
role="menu"
aria-hidden={!isOpen}
>
{/* Menu items */}
</div>
Best Practices
- Server First: Default to Server Components
- Semantic HTML: Use the right element for the job
- Optimize Images: Always use next/image
- Accessibility: Include ARIA labels and keyboard support
- Type Props: Define TypeScript interfaces for all props
- Composition: Break large components into smaller pieces
Anti-Patterns
- Using
divfor buttons and links - Using
imginstead ofnext/image - Adding
'use client'at the top of every file - Inline styles instead of Tailwind classes
- Missing accessibility attributes
- Components with too many responsibilities