Add Frontend Feature
Use this skill when adding frontend pages and components for a feature in the React frontend.
Step 0 — Read references
Read the infrastructure files to understand the current app structure:
apps/frontend/src/routes.tsx— route registrationapps/frontend/src/components/layout/AppSidebar.tsx—NAV_ITEMSarrayapps/frontend/src/components/layout/AppHeader.tsx—TITLE_BY_PATHmapapps/frontend/src/client.ts— Hono type-safe clientapps/frontend/src/components/ui/field.tsx—Field,FieldLabel,FieldError,FieldGroup,FieldSeparatorapps/frontend/src/components/Pagination.tsx— pagination component
Also read the shared components (used by all features — do NOT recreate per-feature):
apps/frontend/src/components/FormCard.tsx—FormCard(monolithic card with header/content/footer, usesuseId()internally) +FormSkeleton(loading skeleton wrapper)apps/frontend/src/components/ListCardHeader.tsx— list page card header with title +renderAction+ search childrenapps/frontend/src/components/AddButton.tsx— shared Add button (Plus icon + link), passed asrenderActiontoListCardHeaderapps/frontend/src/components/EditButton.tsx— shared Edit button (Pencil icon + link), used in view cardrenderActionapps/frontend/src/components/SearchBar.tsx— reusable search form wrapperapps/frontend/src/components/SearchCombobox.tsx— generic searchable combobox (used by feature comboboxes)apps/frontend/src/components/ErrorFallback.tsx— shared error fallback withmessagepropapps/frontend/src/components/NoMatchingItems.tsx— shared empty state for tablesapps/frontend/src/components/StatusBadge.tsx— badge for entity status displayapps/frontend/src/components/DateReadOnlyField.tsx— formatted read-only date fieldapps/frontend/src/components/UncontrolledFormDialog.tsx— dialog with form for state transition actionsapps/frontend/src/components/UncontrolledConfirmDialog.tsx— confirmation dialog for destructive/no-data actionsapps/frontend/src/components/UncontrolledFileUploadDialog.tsx— file upload dialogapps/frontend/src/components/ControlledFormDialog.tsx— externally controlled form dialogapps/frontend/src/components/ControlledConfirmDialog.tsx— externally controlled confirm dialog- Table cell helpers:
TextTableCell,NumberTableCell,DateTableCell,BadgeTableCell,ActionTableCell,LinkTableCell,EditCellButton,ViewCellButton
Then read the code templates (these are the canonical patterns — follow them exactly):
.claude/skills/add-frontend-feature/stores-templates.md— API client + React Query hooks.claude/skills/add-frontend-feature/components-templates.md— components.claude/skills/add-frontend-feature/pages-templates.md— all page types
Step 1 — Create feature directory
apps/frontend/src/features/<entities>/
components/
pages/
stores/
Step 2 — Create stores layer
Follow templates in stores-templates.md:
stores/<entities>Client.ts— raw API functions (list, get, add, edit). Each takestoken?: string | null, uses Hono type-safe client (import { client } from '@/client'), checksresponse.ok.stores/use<Entities>.ts— React Query hooks.useSuspenseQueryfor reads,useMutationfor writes. Mutations invalidate list +setQueryDatafor single entity.
Step 3 — Create components
Follow templates in components-templates.md:
<Entity>SearchBar.tsx— uses sharedSearchBarcomponent, provides filter inputs as children<Entity>Table.tsx— exports<Entity>Table+<Entities>Skeleton. Table reads searchParams for filters and pagination, usesNoMatchingItemsfor empty state,Paginationat bottom, shared table cell components (TextTableCell,NumberTableCell, etc.)<Entity>AddForm.tsx— rendersFormCarddirectly with title, description, fields,onSubmit,onCancel. UsesuseForm+zodResolver,Controllerfields withFieldGroup<Entity>EditForm.tsx— same as Add but withdefaultValues: entity. For features with state transitions:readOnly={!isEditable},renderTitleSuffix={<StatusBadge>},renderAction={<Toolbar>}<Entity>ViewCard.tsx— rendersFormCardwithoutonSubmit, withrenderAction={<EditButton>}and disabled fields<Entity>Skeleton.tsx— loading skeleton usingFormSkeletonfrom@/components/FormCard, shared by Edit and View pages
Step 4 — Create pages
Follow templates in pages-templates.md:
Form/view components render FormCard directly — pages do NOT wrap in Card (except list pages).
List<Entity>Page.tsx—CardwithListCardHeader(title +renderAction={<AddButton>}+ search children) +CardContentwrapping triple-layer tableAdd<Entity>Page.tsx— renders<Entity>AddFormdirectly (no Card wrapping). Mutation hook withtoastEdit<Entity>Page.tsx— error boundary wrapping inner component. Inner component pattern. Two variants: simple (page manages mutation) or with state transitions (inner manages all mutations)View<Entity>Page.tsx— error boundary wrapping inner component that renders<Entity>ViewCard. Inner component pattern
Step 5 — Register routes
File: apps/frontend/src/routes.tsx — add route group under root layout children:
{
path: '<entities>',
children: [
{ index: true, element: <List<Entity>Page /> },
{ path: 'new', element: <Add<Entity>Page /> },
{ path: ':<entityId>', element: <View<Entity>Page /> },
{ path: ':<entityId>/edit', element: <Edit<Entity>Page /> },
],
},
Step 6 — Add sidebar entry
File: apps/frontend/src/components/layout/AppSidebar.tsx — add to NAV_ITEMS:
{ title: '<Entities>', to: '/<entities>', icon: SomeIcon },
Step 7 — Add header title
File: apps/frontend/src/components/layout/AppHeader.tsx — add to TITLE_BY_PATH:
'/<entities>': '<Entities>',
Checklist
-
stores/<entities>Client.ts— raw API functions (list, get, add, edit) usingimport { client } from '@/client' -
stores/use<Entities>.ts— React Query hooks (suspense queries + mutations) -
components/<Entity>SearchBar.tsx— search form using sharedSearchBarcomponent -
components/<Entity>Table.tsx— table + table skeleton + pagination (reads searchParams for page), uses shared cell components -
components/<Entity>AddForm.tsx— rendersFormCarddirectly with form fields -
components/<Entity>EditForm.tsx— rendersFormCarddirectly with entity data as defaults -
components/<Entity>ViewCard.tsx— rendersFormCardwithoutonSubmit, withrenderAction={<EditButton>} -
components/<Entity>Skeleton.tsx— loading skeleton usingFormSkeletonfrom@/components/FormCard -
pages/List<Entity>Page.tsx—Card+ListCardHeaderwithrenderAction={<AddButton>}+ search + triple-layer table -
pages/Add<Entity>Page.tsx— renders<Entity>AddFormdirectly, mutation hook with toast -
pages/Edit<Entity>Page.tsx— error boundary + inner component pattern -
pages/View<Entity>Page.tsx— error boundary + inner component pattern -
routes.tsx— routes registered -
AppSidebar.tsx— nav item added -
AppHeader.tsx— title added
Optional (when applicable)
-
stores/<entities>Client.ts—delete<Entity>()function (if delete needed) -
stores/use<Entities>.ts—useDelete<Entity>()mutation hook (if delete needed) -
components/<Entity>Combobox.tsx— thin wrapper aroundSearchCombobox(if entity is referenced as FK in other features) -
stores/use<Entities>.ts—use<Entities>()non-Suspense hook for combobox search -
components/<Entity>{Action}Action.tsx— action component usingUncontrolledFormDialog,UncontrolledConfirmDialog, orUncontrolledFileUploadDialog -
components/<Entity>Toolbar.tsx— composes action components with status-based enable/disable logic -
utils/status-variants.ts— maps status strings to badge variants viagetStatusVariant() -
stores/use<Entities>.ts— action mutation hooks (e.g.,useConfirm<Entity>,useCancel<Entity>) - Lazy loading with
React.lazy()+Suspensefor heavy components (maps, charts)
Critical rules
FormCardfrom@/components/FormCard— form components renderFormCarddirectly (it includes header/content/footer internally, usesuseId()for form ID)FormSkeletonfrom@/components/FormCard— skeleton components wrap field skeletons insideFormSkeleton- Form components own the card — they render
FormCardwithtitle,description,onCancel,onSubmit, and field children. Pages do NOT wrap forms inCard ListCardHeaderusesrenderAction={<AddButton link="..." text="..." />}— noaddLink/addTextpropsuseSuspenseQueryfor page data fetching —useQueryonly for combobox search hooks (withenabledprop)Controller+zodResolver— neverregister()for formsField,FieldLabel,FieldErrorfrom@/components/ui/field— not shadcnFormField- Triple-layer wrapper:
QueryErrorResetBoundary>ErrorBoundary>Suspense - Inner component pattern for pages that fetch by ID (View, Edit)
@/alias for frontend src imports,#/alias for backend type imports- No
.jsextensions on frontend imports (bundler resolution) - Named exports only — no default exports (except
App.tsx) import typefor type-only imports- Clerk
getToken()passed to every API call - URL search params for pagination (not component state)
toastfromsonnerfor success/error notificationsErrorFallbackfrom@/components/ErrorFallback— shared error component withmessageprop (do NOT create per-feature error components)NoMatchingItemsfrom@/components/NoMatchingItems— shared empty state for tablesuseSearchParamsin components, not hooks — table components readpagefrom searchParams and passpageNumberto hooksFieldGroupwraps form controllers,FieldSeparatordivides form sections- Action components follow
{Entity}{Action}Actionnaming and use shared dialog components (UncontrolledFormDialog,UncontrolledConfirmDialog,UncontrolledFileUploadDialog) - Toolbar components compose action components with status-based
can{Action}booleans - Edit forms with status use
readOnly={!isEditable},renderTitleSuffix={<StatusBadge>},renderAction={<Toolbar>} - View cards follow
<Entity>ViewCardnaming, useFormCardwithoutonSubmit, withrenderAction={<EditButton>}and disabled fields - Comboboxes are thin wrappers around
SearchCombobox<TItem>from@/components/SearchCombobox - Store client files use
import { client } from '@/client'(not relative paths)
Converted and distributed by TomeVault — claim your Tome and manage your conversions.