apps/web UI — routes, @repo/ui, TanStack Start server functions and collections, forms (useForm + createFormSubmitHandler + fieldErrorsAsStrings for Zod field errors), Tailwind layout rules, design-system updates, and useEffect / useMountEffect policy.
apps/web)When to use: apps/web UI — routes, @repo/ui, TanStack Start server functions and collections, forms (useForm with createFormSubmitHandler + fieldErrorsAsStrings when Zod validation errors should appear on fields), Tailwind layout rules, design-system updates, and useEffect / useMountEffect policy.
The project uses React 19. Follow modern patterns and avoid deprecated APIs:
forwardRef — ref is a regular prop in React 19. Declare it in the props type and destructure it directly.ElementRef — use React.ComponentRef<typeof SomeComponent> instead (the ElementRef alias is deprecated).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.use() for consuming promises and context where appropriate.// ❌ 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} />
}
Text from @repo/ui for text contentButton from @repo/ui for buttonsText 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-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.GoogleIcon and GitHubIcon from @repo/ui for OAuth provider iconsPlace 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
-components/ folderdomains/ directories (apps/web/src/domains/) are for state management only: server functions (writes) and collections/queries (reads) — not UI componentspackages/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.apps/web/src/routes/design-system.tsx as the canonical visual inventory for @repo/ui components.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:
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:
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
beforeLoad for middleware-style checks that should block the route tree early: auth redirects, authorization gates, and other preconditions.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.loader once instead of duplicating it across beforeLoad and loader.getRouteApi("...") instead of repeating the route id string in every file.export const Route = createFileRoute("/admin")({
beforeLoad: async () => {
const session = await getSession()
if (!session?.user.isAdmin) throw redirect({ to: "/" })
},
})
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,
}
},
})
const authenticatedRoute = getRouteApi("/_authenticated")
export function useAuthenticatedUser() {
return authenticatedRoute.useLoaderData({ select: (data) => data.user })
}
Key rules:
useState for local UI state (modals, form visibility); no global storesgetQueryClient().invalidateQueries({ queryKey: [...] })useForm + form.Field)createFormSubmitHandler + fieldErrorsAsStrings)When: useForm submits work that can fail with Zod validation (for example server functions using inputValidator), and you want inline errors on @repo/ui fields (not only a toast).
Module: apps/web/src/lib/form-server-action.ts
| Helper | Use |
|---|---|
createFormSubmitHandler | Pass as useForm({ onSubmit: createFormSubmitHandler(async (value) => { ... }, { onSuccess, onError }) }). On validation failure it maps serialized Zod issues onto TanStack Form field meta via extractFieldErrors in apps/web/src/lib/errors.ts. Non-field errors go to onError. On success it resets the form and runs onSuccess. |
fieldErrorsAsStrings | On every Input, Textarea, or other control with an errors prop inside form.Field, set errors={fieldErrorsAsStrings(field.state.meta.errors)} so those meta errors display. |
Always use both when you want Zod-driven field errors: the submit handler wires errors into form state; the helper wires form state into @repo/ui.
Do not duplicate the inline pattern field.state.meta.errors.length > 0 ? field.state.meta.errors.map(String) : undefined — use fieldErrorsAsStrings instead.
Reference: apps/web/src/routes/_authenticated/index.tsx — RenameProjectModal: createFormSubmitHandler in useForm (~line 225), fieldErrorsAsStrings on the name field Input (~line 287).
flex, flex-col, flex-row)m-*, mx-*, my-*, mt-*, etc.)gap utilities for spacing between elements (gap-*, gap-x-*, gap-y-*)p-* (padding) for internal spacing within containerscn)With cn(), use object syntax { "class-name": condition } — not short-circuit condition && "class-name".
// ❌ Bad
<div className={cn("base-class", isActive && "bg-accent")} />
// ✅ Good
<div className={cn("base-class", { "bg-accent": isActive })} />
// ❌ Bad - using margins and space-y
<div className="space-y-4 mt-4">
<div className="mb-2">Item 1</div>
<div className="mb-2">Item 2</div>
</div>
// ✅ Good - using flexbox with gap
<div className="flex flex-col gap-4 pt-4">
<div>Item 1</div>
<div>Item 2</div>
</div>
useEffect policy)useEffect directly in components; use useMountEffect from @repo/ui for mount/unmount-only sync (listeners, imperative widgets, one-time setup).useEffect is unavoidable, add TODO(frontend-use-effect-policy) with a short reason.Prefer: derive values during render; run work in event handlers; controlled vs uncontrolled via value !== undefined; reset by key when an entity id changes.
Avoid: deriving state from props in an effect; fetching in effects to set state; mirroring props into local state; effects as command dispatchers.
import { useMountEffect } from "@repo/ui"
useMountEffect(() => {
const cleanup = subscribeToExternalSystem()
return () => cleanup()
})