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