Conventions
Frontend
Conventions for apps/web — structure, data, forms, UI, auth, types, tests
Read this before building or changing any web feature. These patterns are all live in the codebase — copy an existing feature (features/deals is the reference) rather than inventing.
Structure
- Domain code lives in
features/<domain>/with:api.ts(endpoint strings + typed calls),queries.ts(useQuery hooks),mutations.ts(useMutation hooks),schemas.ts(zod),constants.ts(status configs, labels),components/,hooks/,types.ts(feature-local types only). components/shared/andcomponents/layout/are for cross-feature code only.hooks/at root: onlyuse-auth,use-chat,use-debounced-value.- Pages (
app/**/page.tsx) compose feature components; they don't implement. Target ≤ ~150 lines per page file, ≤ ~250 per component file. Named exports, one main component per file, props interface<Component>Propsabove the component.
Data
The rule that regressed twice
Never fetch in useEffect. No manual loading flags. Both admin review queues regressed to effect-fetching — hold the line.
- Server components fetch via
lib/api/server.ts(serverFetch+ the cached helpers). Public data getsrevalidate; authed data is automaticallyno-store. - Client components use hooks from
features/*/queries.ts/mutations.ts; loading state isisPending/isFetching. - Every endpoint string exists in exactly one
features/*/api.ts. Client transport islib/api/http.ts(goes through the/api/proxyrewrite so the HttpOnly cookie flows). - Query keys come from
qkinlib/api/query-keys.ts— never inline['scraper', 'products']arrays. If a key is missing, add it toqkfirst. Mutations invalidate viaqk.*, not string literals. - Polling: React Query
refetchIntervalreturningfalsewhen done (seeuse-scrape-job.ts). NeversetInterval/sleeploops in components; any long-running loop needs an unmount guard. - Realtime: the chat socket lives in
ChatProvider(hooks/use-chat.tsx), unread counts are socket-pushed — don't add polling for anything the socket already announces.
Forms
- react-hook-form + zod resolver, schema in
features/*/schemas.ts. No multi-useStateforms. - Field-level errors from the resolver; submit state from
formState.isSubmittingor the mutation'sisPending.
UI
- Design tokens only:
text-success,bg-destructive/10,text-warning,text-muted-foreground… Never raw palette (text-amber-600,bg-red-50) or literals (hsl(...), hex) in className. CI greps for literals; treat raw palette classes the same. - Reuse
components/shared/(Pagination,ImageLightbox,ImageUpload,useConfirm/ConfirmDialog,RelativeDate,HeroSection) and@marketplace/uiprimitives. Extend with a prop instead of re-implementing. Never call browserconfirm()— useuseConfirm(). - Status badges read their config from the feature's
constants.ts— one source of truth per status enum. Don't create a second component for the same enum (this regressed once withDealStatusPill). - Icons: lucide-react only. Prices:
formatPricefromlib/format.ts— no inlineNumber(x).toFixed(0). Dates:RelativeDate. - Images:
next/imagewithsizes; neverunoptimized; never<img>.
Auth
middleware.tsis the source of truth for route protection (prefix vs exact matching, admin gate). New protected routes go there, plus a case inmiddleware.test.ts.- Auth cookie is HttpOnly, set only by the
app/api/auth/*route handlers. Client JS never touches the token. JWT_SECRETis a required env var of the web app in production (middleware throws at boot without it).COOKIE_DOMAINis required when web and API are on sibling subdomains.
Types & errors
- No new
any(@typescript-eslint/no-explicit-anyis an error). API types come from@marketplace/types; feature-local types infeatures/*/types.ts. - No swallowed errors (
catch(() => {})). Mutation failures surface viaonError+ toast. Route-level failures are handled by theerror.tsxboundaries — don't add per-component try/catch UI.
Tests
- Pure logic (schemas, utils, builders) gets a colocated
*.test.ts. Stateful hooks/components with real logic get a Testing Library test. - e2e specs in
e2e/must use selectors scoped enough to survive unrelated UI additions (a globalgetByPlaceholder(/search/i)broke when header search shipped). - Anything touching queries/mutations should get an MSW-backed test once the MSW harness exists (see plan §frontend).
Verification gates
pnpm --filter @marketplace/web lint && pnpm --filter @marketplace/web exec tsc --noEmit
pnpm --filter @marketplace/web test
pnpm --filter @marketplace/web build # hermetic: falls back to empty sections if the API is unreachableGreps that must be clean in files you touched:
grep -n "useEffect" <files> | grep -iE "api\.|fetch" # no effect-fetching
grep -n "catch(() => {})" <files>
grep -nE ": any|as any" <files>
grep -n "unoptimized" <files>
grep -nE "(text|bg|border)-(amber|green|red|blue|yellow)-[0-9]" <files> # tokens, not raw palette