Frontend Architecture
Overview
The VerticalFarm OS frontend is a sophisticated Next.js 15 application leveraging React 19's latest features, TypeScript for type safety, and modern UI frameworks. This document details the architecture, patterns, and best practices for frontend development.
Technology Stack
Core Technologies
- Next.js 15 - React framework with App Router
- React 19 - Latest React with concurrent features
- TypeScript - Type-safe development
- Tailwind CSS - Utility-first styling
- shadcn/ui - High-quality component library
- Supabase Client - Database and auth client
Supporting Libraries
- Radix UI - Headless UI primitives
- Lucide Icons - Icon library
- Class Variance Authority - Variant styling
- React Hook Form - Form management
- Zod - Schema validation
Application Structure
Directory Organization
frontend/src/
├── app/ # Next.js App Router
│ ├── (auth)/ # Public authentication routes
│ │ ├── login/
│ │ ├── signup/
│ │ └── reset-password/
│ ├── (app)/ # Protected application routes
│ │ ├── dashboard/
│ │ ├── farms/
│ │ ├── devices/
│ │ └── settings/
│ ├── api/ # API routes
│ ├── layout.tsx # Root layout
│ └── globals.css # Global styles
├── components/
│ ├── features/ # Domain-specific components
│ │ ├── agriculture/ # Farm management
│ │ ├── automation/ # Automation controls
│ │ ├── monitoring/ # Sensor displays
│ │ └── business/ # Analytics
│ ├── ui/ # Reusable UI components
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ ├── dialog.tsx
│ │ └── ...
│ ├── layout/ # Layout components
│ │ ├── Header.tsx
│ │ ├── Sidebar.tsx
│ │ └── Footer.tsx
│ └── overlays/ # Layer overlay components
│ ├── DeviceOverlay.tsx
│ ├── MonitoringOverlay.tsx
│ └── AutomationOverlay.tsx
├── services/ # Service layer (CRITICAL)
│ ├── core/
│ │ ├── BaseService.ts
│ │ ├── AuthService.ts
│ │ └── ErrorService.ts
│ └── domain/
│ ├── farm/
│ ├── device/
│ └── automation/
├── hooks/ # Custom React hooks
│ ├── useAuth.ts
│ ├── useFarms.ts
│ ├── useRealtime.ts
│ └── useOptimistic.ts
├── contexts/ # React Context providers
│ ├── AuthContext.tsx
│ ├── ThemeContext.tsx
│ ├── LayerContext.tsx
│ └── NotificationContext.tsx
├── types/ # TypeScript definitions
│ ├── database.types.ts # Generated from Supabase
│ ├── api.types.ts
│ └── app.types.ts
├── utils/ # Utility functions
│ ├── supabase/
│ │ ├── client.ts
│ │ └── server.ts
│ ├── formatting.ts
│ └── validation.ts
└── styles/ # Additional styles
└── animations.css
Component Architecture
Component Categories
1. Page Components (Server Components by default)
// app/(app)/farms/page.tsx
export default async function FarmsPage() {
// Server-side data fetching
const session = await getServerSession()
const initialFarms = await getFarmsByUser(session.user.id)
return (
<FarmPageClient
initialFarms={initialFarms}
userId={session.user.id}
/>
)
}
2. Client Components (Interactive UI)
'use client'
export function FarmPageClient({ initialFarms, userId }) {
const [farms, setFarms] = useState(initialFarms)
const farmService = FarmService.getInstance()
// Real-time subscriptions
useEffect(() => {
const subscription = subscribeToFarmUpdates(userId, (update) => {
setFarms(prev => updateFarmInList(prev, update))
})
return () => subscription.unsubscribe()
}, [userId])
return <FarmList farms={farms} />
}
3. Feature Components (Business logic)
// components/features/agriculture/FarmManager.tsx
export function FarmManager({ farm }: { farm: Farm }) {
const { user } = useAuth()
const [isEditing, setIsEditing] = useState(false)
const handleUpdate = async (updates: Partial<Farm>) => {
try {
await farmService.updateFarm(farm.id, updates)
toast.success('Farm updated successfully')
} catch (error) {
toast.error('Failed to update farm')
}
}
return (
<Card>
<CardHeader>
<CardTitle>{farm.name}</CardTitle>
</CardHeader>
<CardContent>
{/* Farm management UI */}
</CardContent>
</Card>
)
}
4. UI Components (Reusable primitives)
// components/ui/button.tsx
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export function Button({ className, variant, size, ...props }) {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
State Management
1. Server State (From database)
// Using React Query with service layer
export function useFarms(userId: string) {
const farmService = FarmService.getInstance()
return useQuery({
queryKey: ['farms', userId],
queryFn: () => farmService.getFarmsByUser(userId),
staleTime: 5 * 60 * 1000, // 5 minutes
})
}
2. Client State (UI state)
// Local component state
const [isOpen, setIsOpen] = useState(false)
const [selectedItem, setSelectedItem] = useState<Item | null>(null)
3. Global State (Context API)
// contexts/AuthContext.tsx
export function AuthProvider({ children }) {
const [user, setUser] = useState<User | null>(null)
const [session, setSession] = useState<Session | null>(null)
useEffect(() => {
// Subscribe to auth changes
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(event, session) => {
setSession(session)
setUser(session?.user ?? null)
}
)
return () => subscription.unsubscribe()
}, [])
return (
<AuthContext.Provider value={{ user, session }}>
{children}
</AuthContext.Provider>
)
}
4. Optimistic Updates (React 19)
export function DeviceControl({ device }) {
const [optimisticStatus, setOptimisticStatus] = useOptimistic(
device.status,
(currentStatus, newStatus) => newStatus
)
const toggleDevice = async () => {
const newStatus = !optimisticStatus
// Update UI immediately
setOptimisticStatus(newStatus)
try {
// Update backend
await deviceService.updateStatus(device.id, newStatus)
} catch (error) {
// Revert on error
setOptimisticStatus(!newStatus)
toast.error('Failed to update device')
}
}
return (
<Switch
checked={optimisticStatus}
onCheckedChange={toggleDevice}
/>
)
}
Routing Architecture
App Router Structure
app/
├── (auth)/ # Auth group - public routes
│ ├── layout.tsx # Auth-specific layout
│ ├── login/page.tsx
│ └── signup/page.tsx
├── (app)/ # App group - protected routes
│ ├── layout.tsx # App layout with sidebar
│ ├── dashboard/page.tsx
│ └── farms/
│ ├── page.tsx # Farm list
│ └── [id]/
│ ├── page.tsx # Farm details
│ └── edit/page.tsx # Farm editor
└── api/ # API routes
└── webhooks/
└── stripe/route.ts
Route Protection
// app/(app)/layout.tsx
export default async function AppLayout({ children }) {
const session = await getServerSession()
if (!session) {
redirect('/login')
}
return (
<div className="flex h-screen">
<Sidebar />
<main className="flex-1 overflow-auto">
{children}
</main>
</div>
)
}
Dynamic Routes
// app/(app)/farms/[id]/page.tsx
export default async function FarmPage({
params
}: {
params: { id: string }
}) {
const farm = await farmService.getFarmById(params.id)
if (!farm) {
notFound()
}
return <FarmDetails farm={farm} />
}
Data Fetching Patterns
1. Server-Side Fetching (Recommended)
// Server Component
export default async function Page() {
const data = await fetchData() // Direct database call
return <ClientComponent initialData={data} />
}
2. Client-Side Fetching (When needed)
'use client'
export function ClientComponent() {
const { data, error, isLoading } = useSWR(
'/api/data',
fetcher
)
if (isLoading) return <Skeleton />
if (error) return <Error />
return <DataDisplay data={data} />
}
3. Real-Time Subscriptions
export function useRealtimeData(channel: string) {
const [data, setData] = useState([])
useEffect(() => {
const subscription = supabase
.channel(channel)
.on('postgres_changes', {
event: '*',
schema: 'public',
table: 'sensor_readings'
}, (payload) => {
setData(prev => [...prev, payload.new])
})
.subscribe()
return () => {
subscription.unsubscribe()
}
}, [channel])
return data
}
4. Parallel Data Loading
export default async function DashboardPage() {
// Load data in parallel
const [farms, devices, alerts] = await Promise.all([
farmService.getFarms(),
deviceService.getDevices(),
alertService.getActiveAlerts()
])
return (
<Dashboard
farms={farms}
devices={devices}
alerts={alerts}
/>
)
}
Performance Optimization
1. React 19 Features
// Use cache directive
import { cache } from 'react'
const getCachedFarmData = cache(async (farmId: string) => {
'use cache'
return await farmService.getFarmById(farmId)
})
// Suspense boundaries
export default function Page() {
return (
<Suspense fallback={<Loading />}>
<AsyncComponent />
</Suspense>
)
}
2. Code Splitting
// Dynamic imports
const HeavyComponent = dynamic(
() => import('./HeavyComponent'),
{
loading: () => <Skeleton />,
ssr: false
}
)
3. Image Optimization
import Image from 'next/image'
export function OptimizedImage() {
return (
<Image
src="/farm-image.jpg"
alt="Farm"
width={800}
height={600}
priority
placeholder="blur"
blurDataURL={blurDataUrl}
/>
)
}
4. Bundle Optimization
// next.config.js
module.exports = {
experimental: {
optimizeCss: true,
optimizePackageImports: ['lucide-react', '@radix-ui/react-*']
},
webpack: (config) => {
config.optimization.splitChunks = {
chunks: 'all',
cacheGroups: {
default: false,
vendors: false,
vendor: {
name: 'vendor',
chunks: 'all',
test: /node_modules/
}
}
}
return config
}
}
Styling Architecture
Tailwind CSS Configuration
// tailwind.config.js
module.exports = {
content: ['./src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
// ... more custom colors
},
animation: {
'slide-in': 'slideIn 0.2s ease-out',
'fade-in': 'fadeIn 0.3s ease-in',
},
},
},
plugins: [require('tailwindcss-animate')],
}
CSS Variables for Theming
/* globals.css */
:root {
--primary: 142.1 76.2% 36.3%;
--primary-foreground: 355.7 100% 97.3%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
/* ... more variables */
}
.dark {
--primary: 142.1 70.6% 45.3%;
--primary-foreground: 144.9 80.4% 10%;
/* ... dark mode variables */
}
Component Styling Patterns
// Using cn utility for conditional classes
import { cn } from '@/lib/utils'
export function Card({ className, ...props }) {
return (
<div
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
)
}
Error Handling
Error Boundaries
// components/ErrorBoundary.tsx
export class ErrorBoundary extends React.Component {
state = { hasError: false, error: null }
static getDerivedStateFromError(error) {
return { hasError: true, error }
}
componentDidCatch(error, errorInfo) {
console.error('Error caught by boundary:', error, errorInfo)
// Send to error tracking service
errorService.logError(error, errorInfo)
}
render() {
if (this.state.hasError) {
return <ErrorFallback error={this.state.error} />
}
return this.props.children
}
}
Service Layer Error Handling
// Centralized error handling in services
try {
const result = await farmService.createFarm(data)
toast.success('Farm created successfully')
return result
} catch (error) {
if (error instanceof ValidationError) {
toast.error(error.message)
} else if (error instanceof NetworkError) {
toast.error('Network error. Please try again.')
} else {
toast.error('An unexpected error occurred')
errorService.logError(error)
}
throw error
}
Testing Strategy
Component Testing
// __tests__/FarmCard.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { FarmCard } from '@/components/FarmCard'
describe('FarmCard', () => {
const mockFarm = {
id: '1',
name: 'Test Farm',
location: 'Test Location'
}
it('renders farm information', () => {
render(<FarmCard farm={mockFarm} />)
expect(screen.getByText('Test Farm')).toBeInTheDocument()
expect(screen.getByText('Test Location')).toBeInTheDocument()
})
it('handles click events', () => {
const handleClick = jest.fn()
render(<FarmCard farm={mockFarm} onClick={handleClick} />)
fireEvent.click(screen.getByRole('button'))
expect(handleClick).toHaveBeenCalledWith(mockFarm)
})
})
Integration Testing
// __tests__/integration/FarmCreation.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { FarmCreationFlow } from '@/components/FarmCreationFlow'
describe('Farm Creation Flow', () => {
it('creates a new farm successfully', async () => {
const user = userEvent.setup()
render(<FarmCreationFlow />)
// Fill form
await user.type(screen.getByLabelText('Farm Name'), 'New Farm')
await user.type(screen.getByLabelText('Location'), 'New York')
// Submit
await user.click(screen.getByRole('button', { name: 'Create Farm' }))
// Verify success
await waitFor(() => {
expect(screen.getByText('Farm created successfully')).toBeInTheDocument()
})
})
})
Accessibility
ARIA Attributes
export function AccessibleForm() {
return (
<form aria-label="Create farm form">
<label htmlFor="farm-name">
Farm Name
<span aria-label="required">*</span>
</label>
<input
id="farm-name"
aria-required="true"
aria-invalid={errors.name ? 'true' : 'false'}
aria-describedby="name-error"
/>
{errors.name && (
<span id="name-error" role="alert">
{errors.name.message}
</span>
)}
</form>
)
}
Keyboard Navigation
export function KeyboardNavigableList({ items }) {
const [focusedIndex, setFocusedIndex] = useState(0)
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
setFocusedIndex(prev =>
Math.min(prev + 1, items.length - 1)
)
break
case 'ArrowUp':
setFocusedIndex(prev => Math.max(prev - 1, 0))
break
case 'Enter':
handleSelect(items[focusedIndex])
break
}
}
return (
<ul role="listbox" onKeyDown={handleKeyDown}>
{items.map((item, index) => (
<li
key={item.id}
role="option"
tabIndex={index === focusedIndex ? 0 : -1}
aria-selected={index === focusedIndex}
>
{item.name}
</li>
))}
</ul>
)
}
Development Best Practices
1. Component Guidelines
- Prefer Server Components for pages
- Use Client Components only when needed
- Keep components focused and single-purpose
- Extract reusable logic into hooks
- Use TypeScript for all components
2. Performance Guidelines
- Lazy load heavy components
- Use Suspense boundaries appropriately
- Optimize images with Next.js Image
- Minimize client-side JavaScript
- Cache expensive operations
3. Code Organization
- Group related components together
- Use barrel exports for cleaner imports
- Keep files under 200 lines when possible
- Separate concerns (UI, logic, types)
- Document complex components
4. Testing Guidelines
- Test user interactions, not implementation
- Use integration tests for flows
- Mock external services
- Maintain > 80% coverage for critical paths
- Use snapshot tests sparingly
Summary
The frontend architecture is designed for: - Performance - Fast initial loads and interactions - Scalability - Clean component organization - Maintainability - Clear patterns and conventions - Developer Experience - Modern tooling and practices - User Experience - Responsive, accessible, and intuitive
Follow these patterns and guidelines to maintain consistency and quality across the frontend codebase.
For frontend-specific questions or architectural decisions, consult the frontend team lead or submit proposals for review.