Frontend Architecture
Fidus frontend is built with Next.js 14, React 18, and TypeScript, designed for real-time interaction, AI-driven UI rendering, and privacy-first data handling.
Tech Stack
Core Framework
- Next.js 14: App Router, Server Components
- React 18: Suspense, Server Components, Streaming
- TypeScript: Strict mode, full type safety
UI & Styling
- Tailwind CSS: Utility-first styling
- Radix UI: Accessible component primitives
- Design Tokens: CSS variables for theming
State Management
- React Query: Server state caching
- Zustand: Client state management
- React Hook Form: Form state
Real-time & Data
- WebSocket: Real-time opportunities
- Server-Sent Events: Notifications
- Zod: Runtime validation
State Management Approaches
Server State (React Query / SWR)
For data fetched from the backend API, use React Query for caching, revalidation, and optimistic updates:
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
// Fetch opportunities
export function useOpportunities() {
return useQuery({
queryKey: ['opportunities'],
queryFn: async () => {
const response = await fetch('/api/opportunities');
if (!response.ok) throw new Error('Failed to fetch');
return response.json();
},
refetchInterval: 30000, // Refetch every 30s
staleTime: 10000 // Consider fresh for 10s
});
}
// Dismiss opportunity with optimistic update
export function useDismissOpportunity() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, reason }: { id: string; reason: string }) => {
const response = await fetch(`/api/opportunities/${id}/dismiss`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason })
});
if (!response.ok) throw new Error('Failed to dismiss');
return response.json();
},
onMutate: async ({ id }) => {
// Cancel ongoing queries
await queryClient.cancelQueries({ queryKey: ['opportunities'] });
// Snapshot previous value
const previous = queryClient.getQueryData(['opportunities']);
// Optimistically update
queryClient.setQueryData(['opportunities'], (old: any) => ({
...old,
opportunities: old.opportunities.filter((o: any) => o.id !== id)
}));
return { previous };
},
onError: (err, variables, context) => {
// Rollback on error
if (context?.previous) {
queryClient.setQueryData(['opportunities'], context.previous);
}
},
onSettled: () => {
// Refetch to ensure sync
queryClient.invalidateQueries({ queryKey: ['opportunities'] });
}
});
}Client State (Zustand)
For UI state that doesn't belong to a single component (theme, sidebar open/close, etc.):
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface UIState {
// Theme
theme: 'light' | 'dark' | 'system';
setTheme: (theme: 'light' | 'dark' | 'system') => void;
// Sidebar
sidebarOpen: boolean;
toggleSidebar: () => void;
// Opportunity Surface
opportunitySurfaceOpen: boolean;
openOpportunitySurface: () => void;
closeOpportunitySurface: () => void;
}
export const useUIStore = create<UIState>()(
persist(
(set) => ({
// Theme
theme: 'system',
setTheme: (theme) => set({ theme }),
// Sidebar
sidebarOpen: true,
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
// Opportunity Surface
opportunitySurfaceOpen: false,
openOpportunitySurface: () => set({ opportunitySurfaceOpen: true }),
closeOpportunitySurface: () => set({ opportunitySurfaceOpen: false })
}),
{
name: 'fidus-ui-state',
partialize: (state) => ({
theme: state.theme,
sidebarOpen: state.sidebarOpen
})
}
)
);
// Usage in component
function Header() {
const { theme, setTheme, toggleSidebar } = useUIStore();
return (
<header>
<button onClick={toggleSidebar}>Toggle Sidebar</button>
<button onClick={() => setTheme('dark')}>Dark Mode</button>
</header>
);
}Form State (React Hook Form)
For form handling with validation:
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const budgetFormSchema = z.object({
category: z.string().min(1, 'Category is required'),
amount: z.number().positive('Amount must be positive'),
currency: z.enum(['EUR', 'USD', 'GBP']),
period: z.enum(['daily', 'weekly', 'monthly', 'yearly'])
});
type BudgetFormData = z.infer<typeof budgetFormSchema>;
export function BudgetForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting }
} = useForm<BudgetFormData>({
resolver: zodResolver(budgetFormSchema),
defaultValues: {
currency: 'EUR',
period: 'monthly'
}
});
const onSubmit = async (data: BudgetFormData) => {
await fetch('/api/budgets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label>Category</label>
<input {...register('category')} />
{errors.category && <p>{errors.category.message}</p>}
</div>
<div>
<label>Amount</label>
<input type="number" {...register('amount', { valueAsNumber: true })} />
{errors.amount && <p>{errors.amount.message}</p>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create Budget'}
</button>
</form>
);
}URL State (Next.js searchParams)
For shareable state (filters, pagination, etc.):
'use client';
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
export function TransactionList() {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const category = searchParams.get('category') || 'all';
const page = Number(searchParams.get('page')) || 1;
const updateFilter = (newCategory: string) => {
const params = new URLSearchParams(searchParams);
params.set('category', newCategory);
params.set('page', '1'); // Reset to page 1
router.push(`${pathname}?${params.toString()}`);
};
return (
<div>
<select value={category} onChange={(e) => updateFilter(e.target.value)}>
<option value="all">All Categories</option>
<option value="food">Food</option>
<option value="transport">Transport</option>
</select>
{/* Transactions filtered by URL params */}
<TransactionTable category={category} page={page} />
</div>
);
}Real-time Communication
WebSocket for Opportunity Updates
'use client';
import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
export function useOpportunityWebSocket() {
const queryClient = useQueryClient();
useEffect(() => {
const ws = new WebSocket('wss://api.fidus.ai/opportunities/stream');
ws.onopen = () => {
console.log('WebSocket connected');
};
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
switch (message.type) {
case 'OPPORTUNITY_ADDED':
// Add new opportunity to cache
queryClient.setQueryData(['opportunities'], (old: any) => ({
...old,
opportunities: [message.opportunity, ...old.opportunities]
}));
break;
case 'OPPORTUNITY_UPDATED':
// Update existing opportunity
queryClient.setQueryData(['opportunities'], (old: any) => ({
...old,
opportunities: old.opportunities.map((o: any) =>
o.id === message.opportunityId
? { ...o, ...message.changes }
: o
)
}));
break;
case 'OPPORTUNITY_EXPIRED':
// Remove expired opportunity
queryClient.setQueryData(['opportunities'], (old: any) => ({
...old,
opportunities: old.opportunities.filter(
(o: any) => o.id !== message.opportunityId
)
}));
break;
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
ws.onclose = () => {
console.log('WebSocket disconnected');
// Implement reconnection logic
setTimeout(() => {
console.log('Reconnecting...');
// Recreate WebSocket
}, 5000);
};
return () => {
ws.close();
};
}, [queryClient]);
}
// Usage in layout
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
useOpportunityWebSocket(); // Connect to real-time updates
return <div>{children}</div>;
}Server-Sent Events for Notifications
export function useNotifications() {
const [notifications, setNotifications] = useState<Notification[]>([]);
useEffect(() => {
const eventSource = new EventSource('/api/notifications/stream');
eventSource.addEventListener('notification', (event) => {
const notification = JSON.parse(event.data);
setNotifications((prev) => [notification, ...prev]);
// Show OS notification if permission granted
if (Notification.permission === 'granted') {
new Notification(notification.title, {
body: notification.message,
icon: '/icon.png'
});
}
});
eventSource.onerror = () => {
console.error('SSE error');
eventSource.close();
};
return () => {
eventSource.close();
};
}, []);
return notifications;
}Optimistic Updates Pattern
// Pattern: Update UI immediately, rollback on error
export function useToggleBudgetAlert() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (budgetId: string) => {
const response = await fetch(`/api/budgets/${budgetId}/toggle-alert`, {
method: 'POST'
});
if (!response.ok) throw new Error('Failed');
return response.json();
},
// 1. Optimistic update BEFORE request completes
onMutate: async (budgetId) => {
await queryClient.cancelQueries({ queryKey: ['budgets'] });
const previous = queryClient.getQueryData(['budgets']);
queryClient.setQueryData(['budgets'], (old: any) => ({
...old,
budgets: old.budgets.map((b: any) =>
b.id === budgetId
? { ...b, alertEnabled: !b.alertEnabled }
: b
)
}));
return { previous };
},
// 2. Rollback if error
onError: (err, variables, context) => {
if (context?.previous) {
queryClient.setQueryData(['budgets'], context.previous);
}
toast.error('Failed to toggle alert');
},
// 3. Refetch to ensure sync
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['budgets'] });
}
});
}Routing Structure
app/ ├── layout.tsx # Root layout ├── page.tsx # Homepage (/) ├── (dashboard)/ # Route group │ ├── layout.tsx # Dashboard layout │ ├── page.tsx # Dashboard (/) │ ├── opportunities/ │ │ └── [id]/ │ │ └── page.tsx # Opportunity detail │ ├── calendar/ │ │ ├── page.tsx # Calendar view │ │ └── [eventId]/ │ │ └── page.tsx # Event detail │ ├── finance/ │ │ ├── page.tsx # Finance overview │ │ ├── budgets/ │ │ │ └── page.tsx # Budgets list │ │ └── transactions/ │ │ └── page.tsx # Transactions list │ └── settings/ │ └── page.tsx # Settings ├── api/ # API routes │ ├── opportunities/ │ │ ├── route.ts # GET /api/opportunities │ │ └── [id]/ │ │ ├── dismiss/ │ │ │ └── route.ts # POST dismiss │ │ └── engage/ │ │ └── route.ts # POST engage │ └── ui-decision/ │ └── route.ts # POST /api/ui-decision └── error.tsx # Error boundary
Component Architecture
Server Components (Default)
Use Server Components for static content and data fetching:
// Server Component (default in App Router)
export default async function BudgetPage() {
// Fetch data directly in Server Component
const budgets = await fetch('https://api.fidus.ai/budgets', {
next: { revalidate: 60 } // Cache for 60 seconds
}).then(res => res.json());
return (
<div>
<h1>Budgets</h1>
{budgets.map((budget: Budget) => (
<BudgetCard key={budget.id} budget={budget} />
))}
</div>
);
}Client Components ('use client')
Use Client Components for interactivity:
'use client';
import { useState } from 'react';
export function BudgetCard({ budget }: { budget: Budget }) {
const [expanded, setExpanded] = useState(false);
return (
<div>
<button onClick={() => setExpanded(!expanded)}>
{budget.category}: {budget.spent} / {budget.limit}
</button>
{expanded && (
<div>
<TransactionList budgetId={budget.id} />
</div>
)}
</div>
);
}Composition Pattern
Combine Server and Client Components for optimal performance:
// Server Component
export default async function DashboardPage() {
const opportunities = await fetchOpportunities();
return (
<div>
<h1>Dashboard</h1>
{/* Client Component for interactivity */}
<OpportunitySurface initialOpportunities={opportunities} />
</div>
);
}
// Client Component
'use client';
export function OpportunitySurface({
initialOpportunities
}: {
initialOpportunities: Opportunity[]
}) {
const { data: opportunities } = useOpportunities({
initialData: initialOpportunities // Use server data as initial
});
return (
<div>
{opportunities.map(opp => (
<OpportunityCard key={opp.id} opportunity={opp} />
))}
</div>
);
}Data Fetching
Server-Side (Server Components)
// Fetch with caching
export default async function CalendarPage() {
const events = await fetch('https://api.fidus.ai/calendar/events', {
next: { revalidate: 300 } // Revalidate every 5 minutes
}).then(res => res.json());
return <CalendarView events={events} />;
}
// Fetch without caching (always fresh)
export default async function LiveDashboard() {
const opportunities = await fetch('https://api.fidus.ai/opportunities', {
cache: 'no-store'
}).then(res => res.json());
return <OpportunitySurface opportunities={opportunities} />;
}
// Streaming with Suspense
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<LoadingSkeleton />}>
<OpportunityList /> {/* Streams when ready */}
</Suspense>
<Suspense fallback={<LoadingSkeleton />}>
<UpcomingEvents /> {/* Streams independently */}
</Suspense>
</div>
);
}Client-Side (React Query)
'use client';
export function TransactionList() {
const { data, isLoading, error } = useQuery({
queryKey: ['transactions'],
queryFn: async () => {
const response = await fetch('/api/transactions');
if (!response.ok) throw new Error('Failed to fetch');
return response.json();
}
});
if (isLoading) return <LoadingSpinner />;
if (error) return <ErrorMessage error={error} />;
return (
<div>
{data.transactions.map((tx: Transaction) => (
<TransactionCard key={tx.id} transaction={tx} />
))}
</div>
);
}Performance Considerations
Code Splitting
import dynamic from 'next/dynamic';
// Lazy load heavy components
const BudgetChart = dynamic(() => import('@/components/BudgetChart'), {
loading: () => <ChartSkeleton />,
ssr: false // Don't render on server
});
export function BudgetPage() {
return (
<div>
<h1>Budget Overview</h1>
<BudgetChart /> {/* Loaded only when needed */}
</div>
);
}Image Optimization
import Image from 'next/image';
export function UserAvatar({ user }: { user: User }) {
return (
<Image
src={user.avatarUrl}
alt={user.name}
width={40}
height={40}
priority={false} // Lazy load
placeholder="blur"
blurDataURL="/placeholder.jpg"
/>
);
}Caching Strategies
React Query Cache Configuration
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
cacheTime: 1000 * 60 * 30, // 30 minutes
retry: 3,
refetchOnWindowFocus: true,
refetchOnReconnect: true
}
}
});Benefits
- ✅ Type-Safe: Full TypeScript coverage with Zod validation
- ✅ Real-time: WebSocket and SSE for live updates
- ✅ Optimistic UI: Instant feedback with rollback on error
- ✅ Performance: Server Components, code splitting, caching
- ✅ Developer Experience: Hot reload, TypeScript, React DevTools
- ✅ Scalable: Modular architecture, composable components
Related Documentation
- UI Decision Layer - How UI is determined
- Component Registry - Component resolution
- Opportunity Surface Service - Dashboard backend