Skip to content

Frontend Conventions

Applies to the React/TanStack Start client. Server code (apps/web/src/server/**) follows architecture-conventions.md / service-conventions.md instead.

Prefer a maintained library over bespoke code wherever one fits. The goal is to write as little custom UI plumbing as possible. Reach for these before hand-rolling anything:

NeedUseDo NOT hand-roll
StylingTailwind CSS (v4)bespoke CSS modules / inline style objects
Componentsshadcn/ui (copy-in, lives in @/components/ui)custom button/input/dialog/select/table/tabs/badge/toast
Primitives shadcn lacksRadix UI (@radix-ui/*)custom popover/menu/tooltip/accessible focus mgmt
AnimationFramer Motionmanual CSS keyframes / requestAnimationFrame
Server data + mutationsTanStack Querybespoke fetch-in-useEffect + manual cache
RoutingTanStack Router (file-based, already set up)manual route guards outside loaders
Iconslucide-reactinline SVG paths
Class mergingclsx + tailwind-merge (cn util)string concatenation of classNames
Auth (client)better-auth/react createAuthClientmanual cookie/session handling

Only build a custom component when no maintained library covers the need — and when you do, say why in the PR / handover. A new runtime UI dependency that isn’t in the table above: flag it for the user before adding.

Framer Motion is for subtle, purposeful motion (page/sidebar transitions, modal enter/exit, list reorder). Keep it understated — it must not distract from data entry. Always respect prefers-reduced-motion (gate motion on it).

  • shadcn components are added via its CLI and committed under @/components/ui. Edit the copied source when you need a variant — that’s the shadcn model — but don’t fork a component just to restyle what a Tailwind class would do.
  • Keep page/feature components in apps/web/src/components/<feature>/; keep the shadcn primitives in @/components/ui/ untouched except for intentional variants.
  • Fetch through TanStack Query against the typed /api/v1 client; invalidate on mutation. No business logic in components — call the API, which calls @packages/service (see architecture-conventions.md).
  • Auth-gate admin routes in the route loader (redirect to /login when unauthenticated), not inside component render.

Business-flow components MUST be Playwright-addressable. Full spec + rationale: plans/ARCHITECTURE.md §12.4 (source of truth — wins on any conflict).

  • data-testid="<feature>__<element>__<action>" — kebab-case, double-underscore separator. e.g. user-create__form__submit, user-archive__confirm-yes, user-create__email-input.
  • Mandatory on action surfaces: form submit/cancel, primary actions (Save / Delete / Approve / Reject / Cancel Sesi…), validation-gating inputs, modal confirm/cancel, view-changing tabs/segments, list-row action triggers, critical nav (logout, dashboard tabs). Optional on decorative, read-only display, layout chrome.
  • State via ARIA, never an extra data-qa-statearia-busy (async loading), aria-invalid (bad input), aria-disabled, aria-expanded, aria-selected. shadcn/Radix already set these for accessibility; reusing them means tests also catch a11y regressions.
  • Select by testid, never by CSS class: page.getByTestId('user-create__form__submit') — NOT page.click('button.btn-primary').
  • ESLint enforcement (require-testid-on-action-buttons) is iter-2, not active yet — until then it is a review-time convention.

Web-only UI libs stay in apps/web/package.json (most of the table above). Only promote to the Bun catalog if a 2nd workspace also uses the package — see deps.md.