Skip to content
BoringStack
GitHub

UI template: overview

6 min read

apps/ui

The UI is a Vite + React SPA 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.

Vite

local feedback loop

React 19

SPA shell

OpenAPI

server contract

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.

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.

shape

Component as a folder

Each file has one job; useState in .tsx is a lint error.

state

TanStack Query + Zustand

Server state and client state stay in separate buckets.

contract

OpenAPI-generated client

Wrong paths and body shapes fail the typecheck.

tokens

shadcn/ui + Tailwind

You own primitives in components/ui while theme tokens stay centralized.

tests

E2e against the real stack

Playwright hits the running API directly; there is no mock layer.

agents

Lint keeps the surface honest

The folder anatomy is enforced before review, not remembered by convention.

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.

Route map
Route
Page
Auth
/ and /login
LoginPage
public
/signup
SignUpPage with form and check-your-inbox confirmation
public
/verify-email
VerifyEmailPage with verifying, success, and token-error states
public
/oauth/success
OAuthCallbackPage
public
/dashboard
DashboardPage with welcome, stats, and activity feed
protected
/notifications
NotificationsPage
protected
/notifications/preferences
NotificationsPreferencesPage
protected
/account/invitations
InvitationsPage for team invites
protected
/account/settings
SettingsPage placeholder sections
protected
/account/profile
ProfilePage with read-only useMe fields
protected
*
NotFoundPage
public

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.

UI source map
src/
  • app/App shell: providers, router, main entry
  • features/Vertical feature folders: auth, dashboard, notifications, and yours
  • components/
    • ui/shadcn/ui primitives
    • core/composed components
    • global/app-shell wrappers
  • lib/
    • api/openapi-fetch client + generated schema
    • env/Zod-validated import.meta.env
    • auth/OAuth start helper for the server-side flow
    • logger/structured client logs
    • i18n/react-i18next setup + locales
  • hooks/cross-feature hooks
  • store/app-level Zustand stores

A page or component folder always looks like:

Component folder anatomy
features/dashboard/components/DashboardPage/
  • DashboardPage.tsxpure JSX
  • DashboardPage.hooks.tsall React hooks
  • DashboardPage.types.tsIDashboardPageView
  • DashboardPage.constants.ts
  • DashboardPage.utils.ts
  • DashboardPage.test.tsx
  • DashboardPage.stories.tsx
  • index.tsre-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.

server

Fetched state

*.queries.ts with TanStack Query.

client

UI state

*.store.ts with Zustand for modals, drawers, and step indexes.

form

Input state

*.hooks.ts with React Hook Form and Zod.

view

Render-derived state

*.hooks.ts returns IXxxView; no extra store.

If you can’t tell which bucket something belongs to, that’s almost always a sign the boundary is wrong; not a need for a fifth bucket.

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.

unit

Hooks and utilities

Vitest + Testing Library for hooks, utilities, and schemas.

component

One component 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.

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.

apps/ui on GitHub. Start in src/features/ for the feature shape; src/lib/api/ for the typed client.

  • 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.