This guide covers frontend development patterns and conventions for the Guava frontend.
frontend/
├── app/
│ ├── routes/ # Route components (file-based routing)
│ │ ├── home.tsx # / (index route)
│ │ ├── auth.login.tsx # /auth/login
│ │ ├── p.$token.tsx # /p/:token (public preference entry)
│ │ ├── rotation-schedules.new.tsx # /rotation-schedules/new
│ │ └── rotation-schedules.$id.setup.tsx # /rotation-schedules/:id/setup
│ ├── routes.ts # Route configuration (CRITICAL!)
│ ├── gen/ # Generated protobuf types (from backend)
│ ├── components/ # UI components
│ │ └── ui/ # shadcn/ui components (owned by you)
│ ├── lib/ # Shared utilities
│ │ ├── connect-client.ts # Connect RPC client setup
│ │ ├── auth-context.tsx # Auth state management
│ │ ├── query-provider.tsx # React Query setup
│ │ └── utils.ts # Utility functions (cn helper)
│ ├── root.tsx # Root layout component
│ └── app.css # Global styles (Tailwind + shadcn theme)
CRITICAL: React Router v7 uses explicit
route configuration in frontend/app/routes.ts.
File names like auth.login.tsx do NOT automatically
create routes. You MUST manually register all routes.
When adding a new route: 1. Create the route file
in frontend/app/routes/ (e.g.,
auth.login.tsx) 2. IMMEDIATELY add it to
frontend/app/routes.ts:
typescript export default [ index("routes/home.tsx"), // / (root) route("auth/login", "routes/auth.login.tsx"), // /auth/login // ... other routes ] satisfies RouteConfig;
3. The dev server will hot-reload with the new route
Common mistake: Creating a route file but
forgetting to register it in routes.ts results in 404
errors.
cd frontend
pnpm install # Install dependencies
pnpm run dev # Start dev server (usually on :5173 or :5174)
pnpm run build # Build for production
pnpm run typecheck # Run TypeScript type checking
pnpm run lint # Run Biome linting
pnpm run lint:fix # Auto-fix linting issues
pnpm run format # Format code with Biome
pnpm run format:check # Check code formattingThe frontend uses shadcn/ui - a collection of reusable components built on Radix UI.
Adding New Components:
cd frontend
pnpm dlx shadcn@latest add [component-name] # e.g., button, input, dialog, tabsKey Features: - Components live in
app/components/ui/, you own the code - Built on Radix UI
with accessibility support - Tailwind CSS for styling - Documentation:
https://ui.shadcn.com/docs
Biome handles linting and formatting (replaces ESLint + Prettier).
Code Style: - Double quotes, semicolons required - 2-space indentation, 100 char line width
Type Safety Requirements: - NO type
casting/assertions - Type assertions (as Type)
are prohibited except: - as const for literal type
narrowing - Context default values in React (e.g.,
{} as ContextType) - Use type guards and
validation - Validate data at runtime instead of asserting -
Form data extraction - Use
getRequiredFormField() or
getOptionalFormField() from ~/lib/form-utils
- Strict TypeScript -
noUncheckedIndexedAccess and all strict flags enabled -
No explicit any - Biome enforces
noExplicitAny: "error" - No non-null
assertions - Biome enforces
noNonNullAssertion: "error"
Type Guard Pattern:
// Single source of truth - array defines the type
const VALID_THEMES = ["dark", "light", "system"] as const;
type Theme = (typeof VALID_THEMES)[number];
function isValidTheme(value: string): value is Theme {
return VALID_THEMES.some((t) => t === value);
}
function toTheme(value: string, fallback: Theme = "system"): Theme {
return VALID_THEMES.find((t) => t === value) ?? fallback;
}The frontend uses HTTP-only cookie authentication:
/auth/login → backend sets
session_token HTTP-only cookiecredentials: "include"Key Files: -
frontend/app/lib/auth-context.tsx - Manages auth state
via getCurrentUser() RPC -
frontend/app/lib/connect-client.ts - Provides
transport with automatic cookie handling -
frontend/app/lib/protected-route.tsx - Wrapper component
to protect routes
Making Authenticated API Calls:
import { createClient } from "@connectrpc/connect";
import { UserService } from "~/gen/user/v1/user_pb";
import { transport } from "~/lib/connect-client";
function MyComponent() {
const client = createClient(UserService, transport);
// Cookies automatically included
}Protecting Routes:
import { ProtectedRoute } from "~/lib/protected-route";
export default function MyPage() {
return (
<ProtectedRoute>
<MyPageContent />
</ProtectedRoute>
);
}Use direct useQuery with a null guard. No loaders, no
loading spinners.
import { useQuery } from "@connectrpc/connect-query";
import { listSites } from "~/gen/site/v1/site-SiteService_connectquery";
function SitesContent() {
const { data } = useQuery(listSites);
if (!data) return null;
// data is guaranteed to exist after this point
const sites = data.sites;
return <SitesList sites={sites} />;
}function ScheduleContent({ id }: { id: string }) {
const { data: scheduleData } = useQuery(getSchedule, { id });
const { data: blocksData } = useQuery(listBlocks, { scheduleId: id });
if (!scheduleData || !blocksData) return null;
// Both guaranteed to exist
}clientLoader exports - Loaders
add complexity without benefitisPending / isLoading
checks - Backend is fast, spinners feel slowerif (!data) return null -
Shows nothing until data arrivesdata is guaranteed
- No optional chaining neededMany endpoints require a programId parameter. To
prevent requests from firing with empty programId before
the context is initialized, use the program-scoped query hooks from
~/lib/program-queries.ts.
Available hooks: -
useProgramResidents() - Fetches residents in the current
program - useProgramRotationSchedules() - Fetches
rotation schedules in the current program -
useProgramAcademicYears() - Fetches academic years in the
current program - useProgramCallSchedules() - Fetches
call schedules in the current program
How they work: 1. Each hook checks both
!isLoading and !!currentProgramId before
enabling the query 2. Queries NEVER fire with an empty
programId 3. Components render immediately (no blocking
spinner) while queries wait for programId
Usage:
import { useProgramResidents } from "~/lib/program-queries";
function ResidentsContent() {
const { data } = useProgramResidents();
if (!data) return null;
return <ResidentsList residents={data.residents} />;
}CRITICAL: Do NOT call program-scoped endpoints directly:
// DON'T DO THIS - risks empty programId
const { data } = useQuery(listResidents, { programId: someProgramId ?? "" });
// DO THIS - uses the safe wrapper hook
const { data } = useProgramResidents();For other program-scoped endpoints, follow the
pattern in program-queries.ts:
export function useProgramNewFeature() {
const { currentProgramId, isLoading } = useProgram();
const enabled = !isLoading && !!currentProgramId;
return useConnectQuery(listNewFeature, { programId: currentProgramId ?? "" }, { enabled });
}All mutation → query invalidation relationships are defined in
frontend/app/lib/query-relationships.ts: - Single
source of truth for which queries need invalidation after
each mutation - Automatic invalidation via
MutationCache.onSuccess in query-provider.tsx
Adding a new mutation: 1. Import the mutation from
the connect-query generated file 2. Import any query schemas it
affects 3. Add an entry to mutationEffects:
[createFoo.name]: [listFoos, getFoo],Component mutations:
// DO THIS: Simple useMutation with no onSuccess invalidation
const createMutation = useMutation(createFoo);
// onSuccess is only for UI state changes
const createMutation = useMutation(createFoo, {
onSuccess: () => {
setIsDialogOpen(false);
form.reset();
},
});NEVER manually invalidate in components - the central schema handles it.
frontend/app/routes.ts - Route configuration
(MUST be updated when adding routes)frontend/app/root.tsx - Root layout with
providersfrontend/app/lib/connect-client.ts - RPC client
configurationfrontend/app/lib/auth-context.tsx - Authentication
state managementfrontend/app/lib/query-provider.tsx - TanStack Query
with MutationCache auto-invalidationfrontend/app/lib/query-relationships.ts - Central
mutation → query schemafrontend/app/lib/form-utils.ts - Type-safe form data
extractionfrontend/app/components/ui/ - shadcn/ui
componentsfrontend/biome.json - Biome linter and formatter
configurationfrontend/tsconfig.json - TypeScript
configurationfrontend/app/gen/ - Generated TypeScript types from
protobuf