| name | frontend-engineer |
| description | Expert in Lighthouse Journey Timeline frontend architecture, React patterns, TypeScript, TanStack Query, Zustand, and component development with strong type safety. Use when building UI components, implementing data fetching, managing state, writing forms, styling with Tailwind, testing React components, or integrating with backend APIs. |
Lighthouse Frontend Engineer
Expert knowledge of frontend patterns and architecture for the Lighthouse Journey Timeline.
๐๏ธ Core Architecture
Technology Stack
- Framework: React 18 with TypeScript
- State Management:
- Server State: TanStack Query (React Query)
- Client State: Zustand with devtools middleware
- URL State: wouter (useLocation, useSearchPageQuery)
- Routing: wouter (lightweight React router)
- Styling: Tailwind CSS + lucide-react icons
- Forms: react-hook-form + Zod validation
- Testing: Vitest + React Testing Library + MSW v2
- HTTP Client: Custom fetch wrapper with credentials
Data Flow Architecture
Component (React)
โ
State Decision:
โโโ Server Data โ TanStack Query Hook โ API Service โ fetch (credentials)
โ โ
โ Type-Safe Response (@journey/schema/api)
โ
โโโ UI State โ Zustand Store (devtools)
โ
Type-Safe Types (from @journey/schema)
Key Flow:
- Component calls TanStack Query hook
- Hook uses API service layer
- API service uses fetch with
credentials: "include" - Request/Response types from
@journey/schema/src/api/ - UI state (selections, UI flags) in Zustand
- Server state (data, caching) in TanStack Query
๐ CRITICAL: Where to Look Before Making Changes
Pattern References (ALWAYS CHECK THESE FIRST)
| Pattern | Primary Reference | Secondary Reference |
|---|---|---|
| Reusable Components | packages/components/src/ |
ALWAYS CHECK FIRST |
| API Types (Request/Response) | packages/schema/src/api/ |
CHECK HERE FIRST |
| API Service Layer | packages/ui/src/services/ |
Type-safe API calls |
| TanStack Query Hook | packages/ui/src/hooks/search/useSearchPageQuery.ts |
Query patterns, URL state |
| Zustand Store | packages/ui/src/stores/search-store.ts |
UI state management |
| Query Client Setup | packages/ui/src/lib/queryClient.ts |
Global config, fetch wrapper |
| Token Manager | packages/ui/src/services/token-manager.ts |
JWT storage, singleton pattern |
| Component Test | packages/ui/src/components/nodes/shared/InsightCard.test.tsx |
Testing patterns with MSW |
| Form Patterns | packages/ui/src/components/nodes/job/JobModal.tsx |
react-hook-form + Zod |
| Domain Types | packages/schema/src/types.ts |
Shared type definitions |
| MSW Handlers | packages/ui/src/mocks/profile-handlers.ts |
API mocking patterns |
| Side Panel Component | packages/ui/src/components/timeline/NetworkInsightsSidePanel.tsx |
Figma-based component design |
โก Type Safety & Validation
๐ฏ CRITICAL: Use Enums and Constants, Not Magic Strings
Never use magic strings. Always use enums, constants, or Zod enums for:
- API endpoints: Define in constants file, not inline
- Node types: Use
z.nativeEnum(TimelineNodeType)from shared schema - Status values: Use Zod enums for validation + type inference
- Action types: Use string literal unions or enums
- Route paths: Define as constants with params
- Storage keys: Define as const object, not inline strings
Example - Route Constants:
// โ
GOOD: Constants file
export const ROUTES = {
TIMELINE: '/timeline',
SEARCH: '/search',
NODE_DETAIL: (id: string) => `/timeline/${id}`,
} as const;
// Usage
navigate(ROUTES.NODE_DETAIL(nodeId));
// โ BAD: Magic strings
navigate(`/timeline/${nodeId}`);
Example - Zod Enums:
// โ
GOOD: Shared enum from schema
import { TimelineNodeType } from '@journey/schema';
const NodeTypeSchema = z.nativeEnum(TimelineNodeType);
// โ
GOOD: String literal union
const StatusSchema = z.enum(['draft', 'published', 'archived']);
type Status = z.infer<typeof StatusSchema>;
// โ BAD: Plain strings
type Status = string;
OpenAPI โ TypeScript Types Workflow
0. Generate/Update Types from OpenAPI (When API Changes)
# From packages/server directory
pnpm generate:swagger # Updates openapi-schema.yaml
# Manual type creation in packages/schema based on OpenAPI
# TODO: Add automated OpenAPI โ TypeScript generator when available
1. Check/Create Types in @journey/schema
// packages/schema/src/types.ts
// Generate from OpenAPI schema
export interface CreateNodeRequest {
type: TimelineNodeType;
parentId?: string;
meta: {
title: string;
company?: string;
startDate: string;
endDate?: string;
};
}
// Create Zod schema for validation
export const CreateNodeRequestSchema = z.object({
type: z.nativeEnum(TimelineNodeType),
parentId: z.string().uuid().optional(),
meta: z.object({
title: z.string().min(1).max(200),
company: z.string().optional(),
startDate: z.string().datetime(),
endDate: z.string().datetime().optional(),
}),
});
// Infer type from schema
export type CreateNodeRequestDTO = z.infer<typeof CreateNodeRequestSchema>;
2. Use Types in API Service
// packages/ui/src/services/hierarchy-api.ts
import { CreateNodeRequestDTO, CreateNodeRequestSchema } from '@journey/schema';
export async function createNode(data: CreateNodeRequestDTO) {
// Validate at runtime
const validated = CreateNodeRequestSchema.parse(data);
return httpClient.post<NodeResponse>('/api/v2/timeline/nodes', validated);
}
3. Consume in Component
// Component using the API
import { CreateNodeRequestDTO } from '@journey/schema';
const onSubmit = async (data: CreateNodeRequestDTO) => {
await createNode(data); // Type-safe all the way
};
Type Generation Rules
- Check OpenAPI First:
packages/server/openapi-schema.yaml - Create in @journey/schema: All request/response types
- Add Zod Schemas: For runtime validation
- Export from Index: Make available to both server and client
๐งฉ Component Development
Component Decision Flow
Need Component?
โ
1. Check packages/components/ first โ **ALWAYS START HERE**
โโโ Exists & works? โ Use it
โโโ Doesn't exist or needs changes
โ
2. Is it reusable across features?
โโโ Yes โ Create/extend in packages/components/
โโโ No โ Create in packages/ui/src/components/
Component Structure
packages/components/ # Reusable component library
โโโ src/
โ โโโ ui/ # shadcn/ui base components
โ โโโ custom/ # Custom reusable components
โ โโโ index.ts # Public exports
packages/ui/ # Application-specific
โโโ src/components/
โ โโโ timeline/ # Timeline domain
โ โโโ search/ # Search domain
โ โโโ nodes/ # Node modals & forms
โ โโโ user/ # User components
โโโ pages/ # Route pages
Component Pattern (Figma-Based)
// packages/ui/src/components/timeline/NetworkInsightsSidePanel.tsx
import { ChevronDown, X } from 'lucide-react';
import { useState } from 'react';
// Import from @packages/components if reusable parts exist
import { Card, CardHeader } from '@packages/components';
interface NetworkInsightsSidePanelProps {
data: GraphRAGSearchResponse | undefined;
isLoading: boolean;
matchCount: number;
isOpen: boolean;
onClose: () => void;
onOpenModal: () => void;
}
export function NetworkInsightsSidePanel({
data,
isLoading,
matchCount,
isOpen,
onClose,
onOpenModal,
}: NetworkInsightsSidePanelProps) {
const [isInsightsExpanded, setIsInsightsExpanded] = useState(true);
if (!isOpen) return null;
return (
<div className="fixed bottom-0 right-0 top-[64px] z-50 w-[380px]">
{/* Content matching Figma design specs */}
<div className="flex items-start gap-[9px]">
<div className="h-[92px] w-[4px] bg-[#5c9eeb] rounded-[2px]" />
{/* ... */}
</div>
</div>
);
}
Key Patterns:
- ALWAYS check
packages/components/first before creating - Match Figma designs with exact spacing/colors
- Use lucide-react for icons
- Tailwind for styling with exact px values
- Loading and empty states built-in
- Prop-based show/hide (not CSS-only)
๐ฏ State Management Decision Tree
When to Use What?
graph TD
A[State Needed] --> B{From Server?}
B -->|Yes| C{Needs Caching?}
B -->|No| D{Shared Across Components?}
C -->|Yes| E[TanStack Query]
C -->|No| F{One-time Fetch?}
F -->|Yes| G[Direct API Call in useEffect]
F -->|No| E
D -->|Yes| H{Complex Logic?}
D -->|No| I[Local useState]
H -->|Yes| J[Zustand Store]
H -->|No| K[React Context]
TanStack Query Patterns
// hooks/useNodeData.ts
export function useNodeData(nodeId: string) {
return useQuery({
queryKey: ['nodes', nodeId],
queryFn: () => nodeApi.getNode(nodeId),
staleTime: 5 * 60 * 1000, // 5 minutes
enabled: !!nodeId, // Conditional fetching
retry: 3,
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000)
});
}
// Usage in component
function NodeDetail({ nodeId }) {
const { data, isLoading, error } = useNodeData(nodeId);
if (isLoading) return <Skeleton />;
if (error) return <ErrorMessage />;
return <NodeContent node={data} />;
}
Zustand Store Patterns
// stores/search-store.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
interface SearchState {
// UI state only (NOT server data)
selectedProfileId: string | undefined;
currentQuery: string;
preloadedMatchData: GraphRAGSearchResponse | undefined;
// Actions
setSelectedProfile: (profileId: string | undefined) => void;
setCurrentQuery: (query: string) => void;
clearSelection: () => void;
setPreloadedMatchData: (data: GraphRAGSearchResponse | undefined) => void;
clearPreloadedData: () => void;
}
export const useSearchStore = create<SearchState>()(
devtools(
(set, get) => ({
// Initial state
selectedProfileId: undefined,
currentQuery: '',
preloadedMatchData: undefined,
// Actions
setSelectedProfile: (profileId) => {
set({ selectedProfileId: profileId }, false, 'setSelectedProfile');
},
setCurrentQuery: (query) => {
const currentQuery = get().currentQuery;
// Clear selection when query changes
if (query !== currentQuery) {
set(
{
currentQuery: query,
selectedProfileId: undefined,
},
false,
'setCurrentQuery'
);
}
},
clearSelection: () => {
set({ selectedProfileId: undefined }, false, 'clearSelection');
},
setPreloadedMatchData: (data) => {
set({ preloadedMatchData: data }, false, 'setPreloadedMatchData');
},
clearPreloadedData: () => {
set({ preloadedMatchData: undefined }, false, 'clearPreloadedData');
},
}),
{
name: 'search-store',
}
)
);
Key Patterns:
- Use devtools middleware for debugging
- Store name for DevTools identification
- Action names in set() for debugging
- Keep UI state separate from server state
- Clear related state on actions (e.g., clear selection on query change)
๐ API Integration Patterns
Service Layer Structure
// services/node-api.ts
import { httpClient } from './http-client';
import {
NodeResponse,
CreateNodeRequestDTO,
UpdateNodeRequestDTO,
NodeResponseSchema, // Zod schema for validation
} from '@journey/schema';
class NodeAPI {
async getNode(id: string): Promise<NodeResponse> {
const response = await httpClient.get<NodeResponse>(`/api/v2/nodes/${id}`);
// Validate response matches schema
return NodeResponseSchema.parse(response);
}
async createNode(data: CreateNodeRequestDTO): Promise<NodeResponse> {
const response = await httpClient.post<NodeResponse>('/api/v2/nodes', data);
return NodeResponseSchema.parse(response);
}
async updateNode(
id: string,
data: UpdateNodeRequestDTO
): Promise<NodeResponse> {
const response = await httpClient.patch<NodeResponse>(
`/api/v2/nodes/${id}`,
data
);
return NodeResponseSchema.parse(response);
}
}
export const nodeApi = new NodeAPI();
HTTP Client Integration
// lib/queryClient.ts pattern
export async function apiRequest(
method: string,
url: string,
data?: unknown
): Promise<Response> {
const res = await fetch(url, {
method,
headers: data ? { 'Content-Type': 'application/json' } : {},
body: data ? JSON.stringify(data) : undefined,
credentials: 'include', // Always send cookies
});
if (!res.ok) {
const text = (await res.text()) || res.statusText;
throw new Error(`${res.status}: ${text}`);
}
return res;
}
// Default query function with 401 handling
export const getQueryFn: <T>(options: {
on401: 'returnNull' | 'throw';
}) => QueryFunction<T> =
({ on401 }) =>
async ({ queryKey }) => {
const res = await fetch(queryKey.join('/'), {
credentials: 'include',
});
if (on401 === 'returnNull' && res.status === 401) {
return null;
}
if (!res.ok) {
const text = (await res.text()) || res.statusText;
throw new Error(`${res.status}: ${text}`);
}
return await res.json();
};
Key Patterns:
- HTTP-only cookies (no localStorage tokens)
credentials: "include"on all requests- Global query function for consistency
- 401 handling at query client level
๐งช Testing Patterns
Testing Priority Order (CRITICAL)
- Unit Tests First - Test logic in isolation (functions, hooks, components)
- MSW Integration Tests - Test API interactions
- E2E Tests Last - Only for critical user flows
ALWAYS write or update unit tests BEFORE MSW or integration tests.
Component Testing Setup with MSW
// components/NodeCard.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { vi } from 'vitest';
// MSW is set up globally in test/setup.ts
// Server starts before all tests, resets handlers between tests
// Test wrapper with providers
function TestWrapper({ children }: { children: React.ReactNode }) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false }
}
});
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
describe('NodeCard', () => {
it('should handle user interaction', async () => {
const user = userEvent.setup();
const onEdit = vi.fn();
render(
<TestWrapper>
<NodeCard node={mockNode} onEdit={onEdit} />
</TestWrapper>
);
await user.click(screen.getByRole('button', { name: /edit/i }));
await waitFor(() => {
expect(onEdit).toHaveBeenCalledWith(mockNode);
});
});
});
MSW Setup (Global)
// test/setup.ts
import { beforeAll, afterEach, afterAll } from 'vitest';
import { server } from '../mocks/server';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
MSW Handler Patterns
// mocks/profile-handlers.ts
import { http, HttpResponse } from 'msw';
export const profileHandlers = [
http.get('/api/v2/profiles/:id', ({ params }) => {
const profile = mockProfiles.find((p) => p.id === params.id);
if (!profile) {
return HttpResponse.json({ error: 'Profile not found' }, { status: 404 });
}
return HttpResponse.json(profile);
}),
http.post('/api/v2/profiles', async ({ request }) => {
const body = await request.json();
const newProfile = { id: uuid(), ...body };
return HttpResponse.json(newProfile, { status: 201 });
}),
];
TanStack Query Testing
// Mock the API
vi.mock('../services/node-api', () => ({
nodeApi: {
getNode: vi.fn(),
createNode: vi.fn(),
},
}));
it('should fetch node data', async () => {
const mockNode = { id: '1', title: 'Test' };
vi.mocked(nodeApi.getNode).mockResolvedValue(mockNode);
const { result } = renderHook(() => useNodeData('1'), {
wrapper: TestWrapper,
});
await waitFor(() => {
expect(result.current.data).toEqual(mockNode);
});
});
Zustand Store Testing
// stores/__tests__/app-store.test.ts
import { renderHook, act } from '@testing-library/react';
import { useAppStore } from '../app-store';
describe('AppStore', () => {
beforeEach(() => {
// Reset store state
useAppStore.setState({
selectedNodeId: null,
expandedNodes: new Set(),
});
});
it('should select node', () => {
const { result } = renderHook(() => useAppStore());
act(() => {
result.current.selectNode('node-1');
});
expect(result.current.selectedNodeId).toBe('node-1');
});
});
๐ Form Handling Patterns
react-hook-form + Zod Integration
// components/forms/NodeForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { CreateNodeRequestSchema, CreateNodeRequestDTO } from '@journey/schema';
export function NodeForm({ onSubmit }: { onSubmit: (data: CreateNodeRequestDTO) => void }) {
const form = useForm<CreateNodeRequestDTO>({
resolver: zodResolver(CreateNodeRequestSchema),
defaultValues: {
type: 'job',
meta: {
title: '',
startDate: new Date().toISOString()
}
}
});
const handleSubmit = form.handleSubmit(async (data) => {
try {
await onSubmit(data);
form.reset();
} catch (error) {
// Handle errors - could set form errors
form.setError('root', {
message: 'Failed to create node'
});
}
});
return (
<form onSubmit={handleSubmit}>
<input
{...form.register('meta.title')}
placeholder="Title"
/>
{form.formState.errors.meta?.title && (
<span>{form.formState.errors.meta.title.message}</span>
)}
<button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Saving...' : 'Save'}
</button>
</form>
);
}
Custom Form Components
// components/forms/FormField.tsx
import { Control, FieldPath, FieldValues } from 'react-hook-form';
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@journey/components';
interface FormFieldProps<T extends FieldValues> {
control: Control<T>;
name: FieldPath<T>;
label: string;
placeholder?: string;
}
export function TextField<T extends FieldValues>({
control,
name,
label,
placeholder
}: FormFieldProps<T>) {
return (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem>
<FormLabel>{label}</FormLabel>
<FormControl>
<input
{...field}
placeholder={placeholder}
className="w-full rounded-md border px-3 py-2"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
}
๐ Routing & Navigation (Wouter)
Protected Route Pattern
// components/auth/ProtectedRoute.tsx
import { Redirect } from 'wouter';
import { useAuthStore } from '@/stores/auth-store';
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, isLoading } = useAuthStore();
if (isLoading) {
return <LoadingSpinner />;
}
if (!user) {
return <Redirect to="/signin" />;
}
return <>{children}</>;
}
// App.tsx route setup with wouter
import { Route, Switch } from 'wouter';
<Switch>
<Route path="/signin" component={SignIn} />
<Route path="/dashboard">
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
</Route>
<Route path="/profile">
<ProtectedRoute>
<Profile />
</ProtectedRoute>
</Route>
</Switch>
Navigation State Management
// hooks/useNavigationState.ts
import { useLocation, useRoute } from 'wouter';
export function useNavigationState() {
const [location, setLocation] = useLocation();
const [previousPath, setPreviousPath] = useState<string | null>(null);
useEffect(() => {
setPreviousPath(location);
}, [location]);
const goBack = useCallback(() => {
if (previousPath) {
setLocation(previousPath);
} else {
setLocation('/');
}
}, [previousPath, setLocation]);
return {
currentPath: location,
previousPath,
goBack,
navigate: setLocation,
};
}
Error Boundaries
// components/ErrorBoundary.tsx
import { Component, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: (error: Error) => ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: any) {
console.error('Error caught by boundary:', error, errorInfo);
// Send to error tracking service
}
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback(this.state.error!);
}
return <ErrorFallback error={this.state.error} />;
}
return this.props.children;
}
}
// Usage in App.tsx
<ErrorBoundary fallback={(error) => <AppErrorPage error={error} />}>
<App />
</ErrorBoundary>
๐จ Design System Foundation
Using @journey/components as Base
Design Token Usage
// All components use CSS variables from Tailwind config
// packages/components/tailwind.config.js defines:
// --primary, --secondary, --accent, --destructive, etc.
// Using in custom components
<div className="bg-primary text-primary-foreground">
Uses CSS variable colors
</div>
Ensuring Consistency
- Always check @journey/components first
- Use CVA for variants - maintains consistency
- Use cn() utility for conditional classes
- Follow shadcn/ui patterns for new components
Extending Base Components
// Extend existing component with additional props
import { Button, ButtonProps } from '@journey/components';
interface LoadingButtonProps extends ButtonProps {
isLoading?: boolean;
}
export function LoadingButton({
isLoading,
children,
disabled,
...props
}: LoadingButtonProps) {
return (
<Button disabled={disabled || isLoading} {...props}>
{isLoading && <Spinner className="mr-2" />}
{children}
</Button>
);
}
๐ Common Commands
# Navigate to package first
cd packages/ui
# Development
pnpm dev # Start dev server
pnpm build # Build for production
pnpm type-check # Type checking
# Testing (IMPORTANT: Use smart testing)
pnpm test:changed # โก๏ธ FAST - Only test changed (unit only) - RECOMMENDED
pnpm test:unit # Unit tests only (excludes e2e)
pnpm test # ๐ข SLOW - All tests including e2e
# Specific test file (FASTEST for focused work)
pnpm vitest run --no-coverage src/services/updates-api.test.ts
pnpm vitest --no-coverage src/services/updates-api.test.ts # Watch mode
# From project root
pnpm test:changed # Smart Nx testing - only affected packages
pnpm test:changed:base # Compare to main branch
Testing Strategy:
- Development:
pnpm test:changed(only tests what you changed) - Focused work:
pnpm vitest run --no-coverage [file] - Watch mode:
pnpm vitest --no-coverage [file](auto-rerun) - Pre-commit:
pnpm test:unit(fast, no e2e) - Pre-push:
pnpm test(full suite)
๐ Learning & Skill Updates
How to Update This Skill
- Identify New Pattern: During implementation
- Verify Pattern: Used in multiple places
- Document Location: Add to "Pattern References" table
- Add Example: Include code example if complex
- Update Skill: Edit
.claude/skills/frontend-engineer/SKILL.md
What to Document
- โ New component patterns
- โ Complex TanStack Query patterns
- โ Zustand store patterns
- โ Form validation patterns
- โ Testing strategies
- โ API integration patterns
- โ One-off implementations
- โ Page-specific logic
- โ Temporary workarounds
Pattern Discovery Workflow
- Check @journey/components for existing solutions
- Review similar features in packages/ui
- Check test files for patterns
- Update skill with discoveries
๐ Quick Reference
Essential Hooks
// Auth
useAuthStore(); // User, login, logout
useAuth(); // Auth utilities wrapper
// Data Fetching
useQuery(); // TanStack Query
useMutation(); // TanStack mutations
useInfiniteQuery(); // Pagination
// State
useHierarchyStore(); // Timeline state
useProfileReviewStore(); // Profile state
// Forms
useForm(); // react-hook-form
useFieldArray(); // Dynamic form fields
Component Checklist
- Check packages/components/ first โ CRITICAL
- Check packages/schema/src/api/ for types โ CRITICAL
- Type-safe with TypeScript
- Zod validation for forms
- Accessible (ARIA attributes)
- Responsive design
- Loading states
- Error handling
- Unit tests first, then MSW/integration โ CRITICAL
Performance Checklist
- Use React.memo for expensive components
- Optimize re-renders with useCallback/useMemo
- Lazy load routes with React.lazy
- Image optimization with proper formats
- Bundle splitting at route level
๐ฏ Critical Reminders
Before Any Development:
- โ
Check
packages/components/for reusable components - โ
Check
packages/schema/src/api/for request/response types - โ Write unit tests BEFORE integration/MSW tests
- โ
Use
pnpm test:changedduring development - โ
Use
credentials: "include"for all API calls
Architecture Flow:
Component โ TanStack Hook โ API Service โ fetch (credentials) โ @schema/api types
โ
Zustand (UI state only)
Remember: Maintain type safety across all layers. Always validate with Zod at boundaries. Check component library before creating new components.