This guide documents the component patterns and architectural decisions made during the frontend modernization.
src/
├── app/ # Next.js 15 App Router
│ ├── projects/
│ │ ├── page.tsx # Route component
│ │ ├── loading.tsx # Loading state
│ │ ├── error.tsx # Error boundary
│ │ └── [name]/ # Dynamic routes
├── components/ # Reusable components
│ ├── ui/ # Shadcn base components
│ ├── layouts/ # Layout components
│ └── *.tsx # Custom components
├── services/ # API layer
│ ├── api/ # HTTP clients
│ └── queries/ # React Query hooks
├── hooks/ # Custom hooks
├── types/ # TypeScript types
└── lib/ # Utilities
- Files: kebab-case (e.g.,
empty-state.tsx) - Components: PascalCase (e.g.,
EmptyState) - Hooks: camelCase with
useprefix (e.g.,useAsyncAction) - Types: PascalCase (e.g.,
ProjectSummary)
Guideline: Always use type instead of interface
// ✅ Good
type ButtonProps = {
label: string;
onClick: () => void;
};
// ❌ Bad
interface ButtonProps {
label: string;
onClick: () => void;
}Pattern: Destructure props with typed parameters
type EmptyStateProps = {
icon?: React.ComponentType<{ className?: string }>;
title: string;
description?: string;
action?: React.ReactNode;
};
export function EmptyState({
icon: Icon,
title,
description,
action
}: EmptyStateProps) {
// Implementation
}Pattern: Use React.ReactNode for children
type PageContainerProps = {
children: React.ReactNode;
maxWidth?: 'sm' | 'md' | 'lg';
};Pattern: Use skeleton components, not spinners
// ✅ Good - loading.tsx
import { TableSkeleton } from '@/components/skeletons';
export default function SessionsLoading() {
return <TableSkeleton rows={10} columns={5} />;
}
// ❌ Bad - inline spinner
if (loading) return <Spinner />;Pattern: Use error boundaries, not inline error states
// ✅ Good - error.tsx
'use client';
export default function SessionsError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<Card>
<CardHeader>
<CardTitle>Failed to load sessions</CardTitle>
<CardDescription>{error.message}</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={reset}>Try again</Button>
</CardContent>
</Card>
);
}Pattern: Use EmptyState component consistently
{sessions.length === 0 ? (
<EmptyState
icon={Inbox}
title="No sessions yet"
description="Create your first session to get started"
action={
<Button onClick={handleCreate}>
<Plus className="w-4 h-4 mr-2" />
New Session
</Button>
}
/>
) : (
// Render list
)}Pattern: Create typed query hooks in services/queries/
export function useProjects() {
return useQuery({
queryKey: ['projects'],
queryFn: () => projectsApi.listProjects(),
staleTime: 30000, // 30 seconds
});
}Pattern: Include optimistic updates and cache invalidation
export function useDeleteProject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (name: string) => projectsApi.deleteProject(name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
},
});
}Pattern: Destructure query results
export default function ProjectsPage() {
const { data: projects, isLoading, error } = useProjects();
const deleteMutation = useDeleteProject();
// Use loading.tsx for isLoading
// Use error.tsx for error
// Render data
}<PageContainer maxWidth="xl">
<PageHeader
title="Projects"
description="Manage your projects"
actions={<Button>New Project</Button>}
/>
<PageSection title="Active Projects">
{/* Content */}
</PageSection>
</PageContainer><SidebarLayout
sidebar={<ProjectNav />}
sidebarWidth="16rem"
>
{children}
</SidebarLayout>Pattern: Use FormFieldWrapper for consistency
<FormFieldsGrid>
<FormFieldWrapper
label="Project Name"
description="Unique identifier"
error={errors.name}
>
<Input {...register('name')} />
</FormFieldWrapper>
</FormFieldsGrid>Pattern: Use LoadingButton for mutations
<LoadingButton
type="submit"
loading={mutation.isPending}
disabled={!isValid}
>
Create Project
</LoadingButton>const { execute, isLoading, error } = useAsyncAction(
async (data) => {
return await api.createProject(data);
}
);
await execute(formData);const [theme, setTheme] = useLocalStorage('theme', 'light');const { copy, copied } = useClipboard();
<Button onClick={() => copy(text)}>
{copied ? 'Copied!' : 'Copy'}
</Button>// ✅ Good
type MessageHandler = (msg: SessionMessage) => void;
// ❌ Bad
type MessageHandler = (msg: any) => void;// ✅ Good
const name = project?.displayName ?? project.name;
// ❌ Bad
const name = project ? project.displayName || project.name : '';function isErrorResponse(data: unknown): data is ErrorResponse {
return typeof data === 'object' &&
data !== null &&
'error' in data;
}Pattern: Use dynamic imports for heavy components
const HeavyComponent = dynamic(() => import('./HeavyComponent'), {
loading: () => <Skeleton />,
});Pattern: Set appropriate staleTime
// Fast-changing data
staleTime: 0
// Slow-changing data
staleTime: 300000 // 5 minutes
// Static data
staleTime: Infinity<Button aria-label="Delete project">
<Trash className="w-4 h-4" />
</Button><div
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && handleClick()}
>
{content}
</div>// ✅ User-friendly
"Failed to load projects. Please try again."
// ❌ Technical
"Error: ECONNREFUSED 127.0.0.1:3000"The FTUE (first-time user experience) wizard lives in src/components/onboarding/. It is a dedicated shared component (deliberate exception to colocation) because it may be triggered from multiple entry points.
components/onboarding/
welcome-wizard.tsx # Thin state machine shell (<100 lines)
integration-registry.ts # INTEGRATION_REGISTRY + IntegrationEntry type
use-should-show-onboarding.ts # Trigger hook (zero projects + localStorage)
use-app-config.ts # Reads server config from <meta> tags
steps/
welcome-step.tsx # Step 1: intro
create-workspace-step.tsx # Step 2: workspace creation
integrations-step.tsx # Step 3: connect integrations
completion-step.tsx # Step 4: redirect CTAs
The wizard shell is a thin state machine driven by a STEPS array. Each step receives WizardStepProps (onNext, onSkip, wizardState) and is self-contained. To add a new step, create the component and append it to the STEPS array.
A typed array of IntegrationEntry objects. Each entry provides id, name, description, isConnected(status), and renderCard(props). The integrations step iterates the registry; it never imports individual *ConnectionCard components directly.
A compile-time guard ensures every key of IntegrationsStatus (excluding mcpServers) has a registry entry. Adding a new integration to the API type without a registry entry causes a build error.
WorkspaceForm (in src/components/workspace-form.tsx) does NOT own any mutation. It exposes onSubmit(data), onError(err), and isSubmitting callbacks so consumers control what happens on success. Both CreateWorkspaceDialog and the onboarding wizard use the same form.
welcome-wizard.tsxmust stay under 100 linesWorkspaceFormmust not importuseCreateProjectintegrations-step.tsxmust not import individual connection cardsINTEGRATION_REGISTRYmust cover all non-MCP keys ofIntegrationsStatus- Wizard state persisted to
sessionStoragefor GitHub OAuth redirect must be cleared on completion/dismissal
Key patterns:
- Use
typeoverinterface - Skeleton components for loading
- Error boundaries for errors
- EmptyState for empty lists
- React Query for data fetching
- TypeScript strict mode
- No
anytypes - Proper error messages