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.