Patterns — the catalogue of admin shapes
This is the vocabulary an agent composes from. Each pattern is a page shape (or a building block) with a fixed contract, a canonical example in the repo, and the exact files to copy. When adding to this app, find the closest pattern here, copy its example, and follow its invariants — don't invent a new structure.
Status: ✅ implemented & verified · 🚧 planned (spec below is the build target).
See ROADMAP.md for sequencing. Once a pattern ships, it is reachable through .claude/skills/: the operation skills (add-backend, …) have their own dir, and every UI shape is a component inside the single add-component skill (references/<name>.md + a flat templates/). The Skill column below names either the operation skill or the add-component component.
Catalogue
| Pattern | Status | Use when | Canonical example | Skill |
|---|---|---|---|---|
| CRUD table | ✅ | List rows with server-side paginate/sort/search/filter + create/edit/delete + bulk select | features/products, routes/_app/products.tsx | add-backend |
| Detail / Show | ✅ | View one record (/<resource>/$id) + edit/delete entry | routes/_app/products_.$id.tsx | add-detail-page shape in add-component |
| Master-detail / nested | ✅ | List + record detail in a side panel (selection in URL) | routes/_app/orders.tsx + orders.$id.tsx | add-master-detail shape in add-component |
| Card / grid list | ✅ | Gallery of cards (same data plumbing as the table) | routes/_app/posts.tsx + features/posts/cards.tsx | add-card-list shape in add-component |
| Form page / dialog | ✅ | Create/edit via a shared keyed-remount dialog (or full-page route) | features/products/ProductFormDialog.tsx | add-form shape in add-component |
| Chart page | ✅ | Dashboard/analytics view from datasets | routes/_app/index.tsx (chart components) | add-chart-page shape in add-component |
Building blocks (atoms every pattern uses)
| Block | Status | Notes |
|---|---|---|
DataTable | ✅ | src/infra/table/DataTable.tsx — generic controlled table; URL-synced via useTableSearch, debounced search |
StatusChip, ActionMenu | ✅ | src/infra/ui/* |
| UI primitives (28) | ✅ | src/components/ui/* (shadcn-on-base-ui) |
Form system (TanStack Form + zod + <FormField>) | ✅ | src/components/form/* — TextField/NumberField/SelectField/TextareaField/SubmitButton/FormError |
| Toast | ✅ | sonner via src/lib/toast.ts; mutations report success/error |
<ConfirmDialog> / useConfirm() | ✅ | src/components/ui/confirm-dialog.tsx — promise-based, replaces window.confirm |
Chart components (Area/Bar/Pie/StatCard) | ✅ | src/components/charts/* — generic over row type, CSS-var themed |
| Date / range / combobox / upload | 🚧 | Phase 1+ as needed |
app.config (brand/nav/theme) | ✅ | src/config/app.ts — single rebrand surface |
Data access
| Piece | Status | Notes |
|---|---|---|
Repository<T, TInput> interface | ✅ | src/infra/data/repository.ts — list/getOne/create/update/remove |
drizzleRepository | ✅ | src/infra/data/drizzle-repository.ts — backs products/orders when DATABASE_URL is set |
memoryRepository | ✅ | src/infra/data/memory-repository.ts — zero-config default; backs products/orders with no DB (features/*/demo-data.ts) |
restRepository | ✅ | src/infra/data/rest-repository.ts — backs posts (jsonplaceholder) |
graphqlRepository | ✅ | src/infra/data/graphql-repository.ts — shipped + unit-tested |
AuthProvider seam | ✅ | src/lib/auth-provider.ts (server) + src/lib/auth-client.ts (browser) — auth backend is a swappable preset |
See docs/data-adapters.md for data adapters and docs/backends.md for swapping the data/auth backend.
Business cases (composed verticals)
The sidebar's two business-case groups assemble the patterns above into complete back-offices — the closest thing to "a real app" in the repo, and the best worked examples of composition. products/orders are real Drizzle resources (with an in-memory fallback); the others are memory-backed (features/<name>/ binds memoryRepository(demoX, {…}) directly — zero-config, no Drizzle table) and independently removable.
| Scenario | Routes | Composes |
|---|---|---|
| E-commerce | /, /products, /orders, /customers, /refunds, /posts | chart page (home) · CRUD table · detail · master-detail · card list (posts) |
| Sales (CRM) | routes/_app/crm/* | kanban (deals) · table (contacts) · card list + detail (companies) · charts |
To persist a memory-backed scenario, add a Drizzle table and switch its server.ts to drizzleRepository — the queries / table / form / detail are unchanged.
Shared invariants (every pattern obeys these)
- List params are one shape everywhere:
{ page, pageSize, search?, sortBy?, sortDir?, filters? }→ returns{ rows, total }. - Data only via server functions (
createServerFn), each callingrequireUser()(and laterrequireRole). Adapters/secrets never enter the client bundle. - List/selection state lives in the URL (
validateSearch), not localuseState. - Query keys:
["<resource>", "list", params]/["<resource>", "detail", id]. Mutations invalidate["<resource>"]. - Feedback: mutations show a toast; destructive actions go through
useConfirm(). - States: every pattern handles loading (skeleton), empty, and error explicitly.
- A resource lives in
features/<name>/; a route inroutes/_app/<name>....
✅ CRUD table (reference, implemented)
Files - src/features/products/ — schema.ts (zod + statuses), server.ts (server fns with requireUser), queries.ts (TanStack Query hooks), columns.tsx, config.ts. - src/routes/_app/products.tsx — the page (DataTable + create/edit dialog). - src/infra/table/DataTable.tsx — the generic controlled table.
Contract — page owns list state and passes it to DataTable; DataTable renders toolbar (search/filter/refresh) + table (sortable headers) + pagination, all controlled.
Add one — bun run create-resource <name> scaffolds the whole vertical, then bun run db:generate && bun run db:migrate. (Today: inline drizzle. After Phase 2: the generated server.ts binds a Repository adapter.)
Known gaps vs target — list state is local useState (→ URL, Phase 1); delete uses window.confirm (→ useConfirm, Phase 1); no bulk-select (Phase 3); inline drizzle (→ repository, Phase 2).
🚧 Detail / Show (build target)
Route routes/_app/<resource>/$id.tsx — loader prefetches getOne(id) via ensureQueryData; notFound() when missing. Layout header (title + status + actions: Edit/Delete) → sections of <DescriptionList> (a new atom) → related lists (reuse DataTable/card-list scoped to the parent id). Edit opens the form (dialog or /$id/edit). Invariants: detail query key ["<resource>","detail",id]; breadcrumb to the list.
🚧 Master-detail / nested (build target)
Two flavours, both via nested routes: - Split: routes/_app/<resource>.tsx keeps the list; …/<resource>.$id.tsx renders a side panel <Outlet> beside it (list stays mounted, selection in URL). - Record tabs: …/$id.tsx is a layout with tabbed sub-routes (…/$id/index, …/$id/activity, …/$id/settings). Invariants: selection/active-tab in the URL; the list query stays cached while the panel loads its own detail query.
🚧 Card / grid list (build target)
Same data plumbing as the table — extract a shared useResourceList(repository, params) hook (list state from URL + query). CardList renders a responsive grid of a per-resource <Card> renderer (parallel to columns.tsx, e.g. cards.tsx), reusing the existing toolbar + pagination controls. Use for galleries, people, products-as-cards.
🚧 Atoms (build targets, condensed)
- Form system —
<Form>(wraps TanStack Form'suseFormwith zod Standard-Schema validators) +<FormField name>bound toui/field/ui/label; surfaces server validation errors. Replaces hand-rolled forms. - Toast —
toast.success/error; mutations report through it. - ConfirmDialog —
const ok = await confirm({title, description, destructive}). - Charts —
components/charts/{AreaChart,BarChart,PieChart,StatCard}.tsx, themed vialib/color-theme, responsive, consistent tooltip/legend. Dashboard refactors onto them. - app.config —
src/config/app.ts:{ name, logo, nav, theme }; consumed by the sidebar, root head, and auth pages. The single place to rebrand.
🚧 Data-source adapter (portability-critical)
// src/infra/data/repository.ts
export interface ListParams {
page: number; pageSize: number;
search?: string; sortBy?: string; sortDir?: "asc" | "desc";
filters?: Record<string, string>;
}
export interface ListResult<T> { rows: T[]; total: number; }
export interface Repository<T, TInput> {
list(params: ListParams): Promise<ListResult<T>>;
getOne(id: string): Promise<T | null>;
create(input: TInput): Promise<T>;
update(id: string, input: Partial<TInput>): Promise<T>;
remove(id: string): Promise<void>;
}
Adapters implement the same interface: - drizzleRepository(table, { searchColumns, sortColumns }) — Postgres via Drizzle. - restRepository<T>({ baseUrl, path, map }) — proxy to an existing REST API (called inside a server fn, so credentials stay server-side). - graphqlRepository(...) — same interface over a GraphQL endpoint.
A resource's server.ts picks an adapter; the rest of the vertical (queries, table, detail, card-list) is unchanged regardless of backend. This is what "port it into the shape I need" relies on — swap the adapter, keep the shapes.