UI template: overview
The UI is a Vite + React single-page app with a typed OpenAPI client: fast local feedback and a compile-time contract with the API. Architecture rules keep feature folders small enough for humans and agents to change safely.
A production-shaped SPA. Architecture rules (component anatomy, queries vs stores, OpenAPI client) keep features from turning into 600-line .tsx blobs as the codebase grows.
How a feature is shaped
Section titled “How a feature is shaped”flowchart LR page["MyPage.tsx<br/>pure JSX, no state"] hook["MyPage.hooks.ts<br/>useState · useEffect · useCallback"] types["MyPage.types.ts<br/>IMyPageView (hook's return shape)"] query["my-feature.queries.ts<br/>TanStack Query"] store["my-feature.store.ts<br/>Zustand (UI state)"] page --> hook hook --> query hook --> store hook -.->|returns| types page -.->|reads| types
Each UI feature folder splits into role-specific files: a pure-JSX
.tsx renders what its hook returns; a .hooks.ts owns
all React hooks plus the calls into TanStack Query and Zustand; a
.types.ts declares the view-object shape the component reads.
Components never touch queries, stores, or env directly.
Components only ever see the view object from their hook. They never read TanStack Query directly, never read Zustand directly, never read import.meta.env directly. That’s what makes any component trivially testable.
Design choices
Section titled “Design choices”Component as a folder. Each file has one job; useState in .tsx is a lint error.
TanStack Query + Zustand. Server state and client state stay in separate buckets.
OpenAPI-generated client. Wrong paths and body shapes fail the typecheck.
shadcn/ui + Tailwind. You own UI components in components/ui while theme tokens stay centralized.
E2e against the real stack. Playwright hits the running API directly; there is no mock layer.
Lint keeps the surface honest. The folder anatomy is enforced before review, not remembered by convention.
Routes & shell
Section titled “Routes & shell”Every authenticated route renders inside AppShell: a brand-marked left sidebar (AppSidebar with NavLink + aria-[current=page]: Tailwind active styling), a sticky header (account switcher · notification bell · theme toggle · logout), and the page content. On mobile the sidebar collapses into a Sheet drawer triggered from the header.
By default the shell covers the full auth surface (login, signup, verify-email, OAuth callback), the dashboard, the notifications feed and preferences, and the account pages (profile, invitations, settings). SettingsPage ships with an explicit “placeholder, fill this in” copy block so a fork knows the page is wired into the nav but the form is yours to write.
File layout
Section titled “File layout”Under src/:
app/- App shell: providers, router, main entryfeatures/- Vertical feature folders: auth, dashboard, notifications, and yourscomponents/-ui/(shadcn/ui base components),core/(composed components),global/(app-shell wrappers)lib/-api/(openapi-fetch client + generated schema),env/(Zod-validated import.meta.env),auth/(OAuth start helper),logger/(structured client logs),i18n/(react-i18next setup + locales)hooks/- cross-feature hooksstore/- app-level Zustand stores
A page or component folder always has the same 8-file layout (enforced by the UI lint rules):
ComponentName.tsx- pure JSXComponentName.hooks.ts- all React hooksComponentName.types.ts- IComponentNameViewComponentName.constants.tsComponentName.utils.tsComponentName.test.tsxComponentName.stories.tsxindex.ts- re-export
Stories ship 1:1 with the components and run under a global theme decorator (@storybook/addon-themes wired in .storybook/preview.tsx), so every story has a light/dark toggle in the Storybook toolbar with no per-story plumbing.
bun run new:component <Name> writes this anatomy. bun run new:feature <name> writes a feature scaffold.
State, in one decision
Section titled “State, in one decision”Fetched state (server). Use *.queries.ts with TanStack Query.
UI state (client). Use *.store.ts with Zustand for modals, drawers, and step indexes.
Input state (form). Use *.hooks.ts with React Hook Form and Zod.
Render-derived state (view). *.hooks.ts returns IXxxView; no extra store.
If you can’t tell which bucket something belongs to, that is almost always a sign the boundary is wrong, not a need for a fifth bucket.
The typed OpenAPI client
Section titled “The typed OpenAPI client”The API publishes /swagger/json. bun run generate:api reads it and emits the typed client. From there apiClient.GET("/api/v1/users/me") autocompletes the path and types the response. Drift between server and client becomes a compile error, not a runtime 500.
See OpenAPI client.
Testing
Section titled “Testing”Unit (hooks and utilities). Vitest + Testing Library for hooks, utilities, and schemas.
Component (one surface). Render the component and its hook together, not a mocked UI stub.
E2e (real stack). Playwright runs against the API and UI from Compose.
Visual (snapshot baselines). Per-platform baselines catch layout drift before release.
See Testing.
Lint as the contract
Section titled “Lint as the contract”The component anatomy is held in place by @boring-stack-pkg/eslint-plugin-react-component-architecture. TanStack Query cache consistency on *.queries.ts is enforced by @boring-stack-pkg/eslint-plugin-tanstack-query-cache; static translation keys by @boring-stack-pkg/eslint-plugin-i18n-keys. Those sit alongside the shared plugin family. See Lint as the contract for the full inventory.
Source
Section titled “Source”apps/ui on GitHub. Start in src/features/ for the feature shape; src/lib/api/ for the typed client.
Related
Section titled “Related”- Architecture rules; the component anatomy the lint enforces.
- OpenAPI client; how the React app stays in sync with the API.
- Testing; the three test layers and why there’s no mock layer.
- i18n; type-safe translation keys with linted JSX strings.
- Notifications; the in-app surface for system and user events.