- name
- web-frontend
- description
- apps/web UI — routes, @repo/ui, TanStack Start server functions and collections, navigation (Link vs useNavigate), forms (useForm + createFormSubmitHandler + fieldErrorsAsStrings for Zod field errors), Tailwind layout rules, design-system updates, and useEffect / useMountEffect policy.
# Web app frontend (`apps/web`)
**When to use:** `apps/web` UI — routes, `@repo/ui`, TanStack Start server functions and collections, **navigation (`Link` vs `useNavigate`)**, forms (**`useForm`** with **`createFormSubmitHandler`** + **`fieldErrorsAsStrings`** when Zod validation errors should appear on fields), Tailwind layout rules, design-system updates, and **`useEffect` / `useMountEffect` policy**.
## Required design context
Before designing or editing any user-facing UI, read the root [`design.md`](../../../design.md). It defines the Latitude product UI rules for reuse-first discovery, composition, hierarchy, spacing, actions, responsive behavior, and route versus modal versus drawer decisions.
Treat `packages/ui/src/components/`, `packages/ui/src/tokens/`, the live `apps/design-system` inventory, and adjacent `apps/web` routes as the implementation source of truth. Search for an existing component and a comparable in-product pattern before creating a component, introducing custom styling, or adding a new token. Follow `design.md` when deciding whether new UI is justified; follow this skill for the required React, TanStack, route, form, and component APIs.
For project-route UI, use the local `-components/section-header.tsx` `SectionHeader` once for the route-level header, matching the Behaviors page's `Layout.Header` composition. A project overview defaults to the standard variant so its title has the same size and weight as the Behaviors route header; use `variant="xl"` only when the task and a comparable existing route justify it. Import the existing header and layout rather than recreating their structure or classes locally.
Project overview content has a strict default hierarchy: metric values may use `Text.H5`; panels use muted `Text.H6` labels; repeated row and issue titles use compact `Text.H6B`; supporting copy uses muted `Text.H6`. Do not use `Text.H3*`, `Text.H4*`, or `Text.H5*` as panel, row, issue, or block titles. Do not use `SectionHeader` as a panel heading.
For project overview metrics, first inspect the compact `AggregationItem` strips in `users/-components/users-analytics-panel.tsx`, `tools/-components/tools-analytics-panel.tsx`, and `-components/aggregations/general-aggregations.tsx`. Preserve that anatomy and keep metrics in one horizontally scrollable row; do not create a grid, separate `Card` per metric, or a route-local approximation of the existing aggregation item. Detail sections use muted labels plus `divide-y` rows, not nested cards or dashboard-specific mini-panels. Read the `Project Overview Contract` in `design.md` before implementation.
## Existing V2 UI reference
- Before building new UI, inspect comparable V2 routes and components in `apps/web/src/routes/` and `packages/ui/src/components/`.
- Reuse the closest V2 interaction and composition pattern when it is solid; preserve consistency across the active product rather than recreating the pattern locally.
- Treat the V2 application and `apps/design-system` as the visual and behavioral reference. Do not use V1 as a default source of UI guidance.
## React 19
The project uses **React 19**. Follow modern patterns and avoid deprecated APIs:
- **No `forwardRef`** — `ref` is a regular prop in React 19. Declare it in the props type and destructure it directly.
- **No `ElementRef`** — use `React.ComponentRef<typeof SomeComponent>` instead (the `ElementRef` alias is deprecated).
- **No gratuitous `useMemo` / `useCallback` / `React.memo`** — the React Compiler (enabled in the build) auto-memoizes. Only add manual memoization when profiling shows a concrete bottleneck; remove existing wrappers when they have no measured benefit.
- **Prefer `use()`** for consuming promises and context where appropriate.
```tsx
// ❌ Deprecated React 18 pattern
const Input = forwardRef<ElementRef<typeof Primitive>, InputProps>(({ className, ...props }, ref) => (
<Primitive ref={ref} {...props} />
))
Input.displayName = "Input"
// ✅ React 19 — ref is a regular prop
function Input({ className, ref, ...props }: InputProps & { ref?: React.Ref<React.ComponentRef<typeof Primitive>> }) {
return <Primitive ref={ref} {...props} />
}
```
## Components
- **Always** use `Text` from `@repo/ui` for text content
- **Always** use `Button` from `@repo/ui` for buttons
- **Do not** nest `Text` inside `Button`. `Button` already sets font size, weight, and color; use plain text (and optional icons) as direct children. Wrapping the label in `Text` duplicates styles (e.g. avoid `<Button><Text.H5>Save</Text.H5></Button>`).
- **Lucide icons:** import from `lucide-react` and pass the component to `@repo/ui`’s `Icon` via the `icon` prop (e.g. `<Icon icon={Pencil} size="sm" />`). Prefer that over raw `<Pencil />` so shared sizing and color tokens apply. Buttons and other primitives that accept an `icon` prop follow the same pattern; otherwise wrap with `Icon`.
- **Always** use `GoogleIcon` and `GitHubIcon` from `@repo/ui` for OAuth provider icons
## Modal: short form vs composition
**Default to the short form** — `<Modal title=… description=… footer=… open dismissible onOpenChange={…}>{children}</Modal>`. It auto-wraps `children` in `Modal.Body` with the standard `px-6` padding and the same scroll behavior every other modal uses, so content inside it can't drift out of the design system. This is the shape `RenameProjectModal` (the form reference cited above) uses — treat it as the canonical pattern.
```tsx
<Modal
open
dismissible
onOpenChange={(next) => (!next ? onClose() : undefined)}
title="Change sampling rate"
description="Percentage of incoming traces this evaluation runs against."
footer={
<>
<CloseTrigger />
<Button onClick={() => void form.handleSubmit()}>Save</Button>
</>
}
>
<form onSubmit={…}>{/* fields */}</form>
</Modal>
```
Reach for the composition form (`Modal.Root` / `Modal.Content` / `Modal.Header` / `Modal.Body` / `Modal.Footer`) **only** when the short form can't express what you need:
- Custom header JSX beyond a string `title` + `description` — render `<Modal.Header>{custom JSX}</Modal.Header>` directly.
- Multiple body regions, or content that must opt out of the standard body padding / scroll.
- Conditional rendering of header or footer that can't be driven by passing `undefined`.
Notes:
- `dismissible` defaults to `false` — pass it to show the close X.
- `scrollable` defaults to `true`. For short bodies that shouldn't grow to fill height, pass `scrollable={false}`.
- **Do not** wrap your children in your own `<Modal.Body>` while using the short form — children are already wrapped, double-wrapping nests padding.
## Route-level component organization
Place React components close to the routes that use them, inside a `-components/` subfolder within the route directory. This keeps route files (which TanStack Router auto-discovers) clearly separated from supporting components.
```
routes/_authenticated/projects/$projectId/datasets/
├── index.tsx # route file
├── $datasetId.tsx # route file
└── -components/ # supporting components for these routes
├── dataset-table.tsx
├── row-detail-panel.tsx
└── version-badge.tsx
```
- Route files live directly in the route directory — TanStack Router discovers them
- Supporting UI for those routes lives in the adjacent `-components/` folder
- `domains/` directories (`apps/web/src/domains/`) are for state management only: server functions (writes) and collections/queries (reads) — **not** UI components
## Design system showcase
- When adding a new implemented UI component in `packages/ui` (or replacing a placeholder export with a real implementation), update `apps/web/src/routes/design-system.tsx` to include a usage example for that component in both light and dark mode previews.
- Treat `apps/web/src/routes/design-system.tsx` as the canonical visual inventory for `@repo/ui` components.
## State management (TanStack)
The web app uses a **server-centric, query-driven** architecture built on the TanStack ecosystem. No Zustand, Redux, or global stores.
**Server functions** — All data fetching and mutations use `createServerFn` from `@tanstack/react-start`:
```typescript
import { Effect } from "effect"
import { ProjectRepository, createProjectUseCase } from "@domain/projects"
import { ProjectRepositoryLive, SqlClientLive } from "@platform/db-postgres"
import { getPostgresClient } from "../../server/clients.ts"
// Query (GET)
export const listProjects = createServerFn({ method: "GET" }).handler(async () => {
const { organizationId } = await requireSession()
const client = getPostgresClient()
return await Effect.runPromise(
Effect.gen(function* () {
const repo = yield* ProjectRepository
return yield* repo.findAll()
}).pipe(
Effect.provide(ProjectRepositoryLive),
Effect.provide(SqlClientLive(client, organizationId)),
),
)
})
// Mutation (POST) with Zod validation
export const createProject = createServerFn({ method: "POST" })
.inputValidator(createProjectSchema)
.handler(async ({ data }) => {
const { userId, organizationId } = await requireSession()
const client = getPostgresClient()
return await Effect.runPromise(
createProjectUseCase({...}).pipe(
Effect.provide(ProjectRepositoryLive),
Effect.provide(SqlClientLive(client, organizationId)),
),
)
})
```
Server functions live in `apps/web/src/domains/*/functions.ts`.
**Collections** — Client-side reactive state uses TanStack React DB + Query via `queryCollectionOptions`:
```typescript
const projectsCollection = createCollection(
queryCollectionOptions({
queryClient,
queryKey: ["projects"],
queryFn: () => listProjects(),
getKey: (item) => item.id,
onInsert: async ({ transaction }) => { /* optimistic insert */ },
onUpdate: async ({ transaction }) => { /* optimistic update */ },
onDelete: async ({ transaction }) => { /* optimistic delete */ },
}),
)
export const useProjectsCollection = (...) => useLiveQuery(...)
```
Collection files live in `apps/web/src/domains/*/collection.ts`.
**Route middleware vs route data**
- Use `beforeLoad` for middleware-style checks that should block the route tree early: auth redirects, authorization gates, and other preconditions.
- Use `loader` for data the route or layout actually renders. This keeps rendered data in TanStack Router's loader lifecycle, so it can use `staleTime`, `useLoaderData({ select })`, and avoid unnecessary refetching on same-route search-param navigations.
- If the same lookup is both your guard and your rendered data source, prefer doing that work in `loader` once instead of duplicating it across `beforeLoad` and `loader`.
- When multiple descendant routes need parent loader data, prefer a small route-scoped wrapper around `getRouteApi("...")` instead of repeating the route id string in every file.
```typescript
export const Route = createFileRoute("/admin")({
beforeLoad: async () => {
const session = await getSession()
if (!session?.user.isAdmin) throw redirect({ to: "/" })
},
})
```
```typescript
export const Route = createFileRoute("/_authenticated")({
staleTime: Infinity,
loader: async () => {
const session = await getSession()
if (!session) throw redirect({ to: "/login" })
const sessionData = session.session as Record<string, unknown>
const organizationId =
typeof sessionData.activeOrganizationId === "string" ? sessionData.activeOrganizationId : null
if (!organizationId) throw redirect({ to: "/welcome" })
return {
user: session.user,
organizationId,
}
},
})
```
```typescript
const authenticatedRoute = getRouteApi("/_authenticated")
export function useAuthenticatedUser() {
return authenticatedRoute.useLoaderData({ select: (data) => data.user })
}
```
**Key rules:**
- Server functions are the only data-fetching mechanism — no direct REST API calls from the client
- Use collections for reactive, queryable client state with automatic server sync
- Use `useState` for local UI state (modals, form visibility); no global stores
- Invalidate query cache after mutations: `getQueryClient().invalidateQueries({ queryKey: [...] })`
- Forms use TanStack React Form (`useForm` + `form.Field`)
## Navigation: `<Link>` vs `useNavigate`
**Rule:** if navigation is triggered by a user clicking something, render a **`<Link>`** (from `@tanstack/react-router`). Reserve **`useNavigate`** for *programmatic* redirects — work the user didn't directly click "go there" for.
A real anchor (`<Link>` → `<a href>`) is the only way to get:
- `href` (hover-preview the URL, right-click → copy/open, browser history correctness)
- **cmd/ctrl-click and middle-click to open in a new tab**
- Keyboard activation (Enter) and focus-visible handling for free, no manual `onKeyDown`
- Works before client-side JS has hydrated
| Trigger | Use |
| --- | --- |
| User clicks a button / badge / row / link-styled element to go to a different page | **`<Link>`** |
| Same-route navigation that only flips a search param (drawer toggle, "apply saved search" on the page you're already on) | `useNavigate` is fine — there is no new document to link to |
| After a mutation completes (`createX` → go to the new resource, `deleteX` → go back to the listing) | `useNavigate` |
| Auth flows (sign in, sign out, OAuth callback, redirect-after-login) | `useNavigate` |
### Anti-pattern to avoid
Do **not** put `role="button"`, `onClick`, and a hand-rolled `onKeyDown` on a `Badge`/`div`/`span` to call `navigate(...)`. That re-implements (badly) what `<Link>` already provides, and silently breaks cmd-click / middle-click / right-click flows.
```tsx
// ❌ Bad — click-to-navigate via useNavigate
const navigate = useNavigate()
function open() {
navigate({ to: "/projects/$projectSlug/settings/flaggers", params: { projectSlug }, search: { flagger: slug } })
}
return (
<Badge
role="button"
tabIndex={0}
onClick={open}
onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && open()}
>
{label}
</Badge>
)
// ✅ Good — real anchor wraps the visual badge
return (
<Link
to="/projects/$projectSlug/settings/flaggers"
params={{ projectSlug }}
search={{ flagger: slug }}
aria-label={`Open the ${name} flagger settings`}
className="inline-flex"
>
<Badge variant="secondary" size="small" className="cursor-pointer hover:bg-muted">
{label}
</Badge>
</Link>
)
```
View on GitHub