## Context This is largely just a mechanical change here: Opting to use lucide icons with the `Icon` suffix across all icon imports within `studio` - so that it’s clearer which components are icons + reduces the possibility of name collision with our own components - e.g <User /> vs <UserIcon /> Also added a eslint rule for this: <img width="1069" height="167" alt="image" src="https://github.com/user-attachments/assets/7f36f443-5ca2-4bf6-8d22-86e774513bfc" /> And updated `AGENTS.md` as such: ``` Icons come from `lucide-react`, always imported with the `Icon` suffix (`import { CheckIcon, Trash2Icon } from 'lucide-react'`) — never the bare names (`Check`) or `Check as CheckIcon` aliases (lint-enforced). ```
11 KiB
Supabase Studio
Next.js pages router + TanStack Start (mid-migration, see below), React 19. Dev server: pnpm dev:studio → http://localhost:8082.
Skills — load before working
Load the skills matching the task; stack them when a task spans areas:
| Task | Additional skills |
|---|---|
Query/mutation hooks, query keys (data/**) |
studio-queries |
| UI: pages, forms, tables, charts, sheets, empty states | studio-ui-patterns |
| Form logic: react-hook-form fields, watch/formState, reset, number inputs | react-hook-form |
| Displaying API errors | studio-error-handling |
| Tests (deciding, writing, reviewing) | studio-testing, then studio-mock-api-tests (component/MSW) or studio-e2e-tests (Playwright) |
| PostHog event tracking | telemetry-standards |
| SQL against user databases | safe-sql-execution |
Logs Explorer SQL, data/logs |
clickhouse-logs-queries |
| Component API design, boolean-prop refactors | vercel-composition-patterns |
| Keyboard shortcuts, search/filter inputs | studio-shortcuts |
| User-facing copy | copywriting |
TanStack Start migration
Studio is migrating from the Next.js pages router (pages/**) to TanStack Start (routes/**). Both runtimes ship side-by-side; the STUDIO_FRAMEWORK env var selects which one pnpm dev/build runs (default: next, resolved in scripts/dispatch.js). Full route map and strategy: TANSTACK_MIGRATION.md.
- Never delete a page file. Most
routes/**files are thin wrappers re-exporting the default export of theirpages/**counterpart, so the Next file is load-bearing for both runtimes until the final cleanup pass. - Pure page-body edits propagate to the route automatically. Mirror a change by hand into the corresponding
routes/**file only when it touches what the route duplicates:getLayout/layout wrapping, page titles or otherstaticData(incl.skip*Layoutflags),withAuth, or redirect paths. - A new page under
pages/**needs a matching route underroutes/**plus a checklist entry inTANSTACK_MIGRATION.md. - New code uses native TanStack APIs — no
next/routerornext/link. Thecompat/next/shims exist only for legacy re-exported pages. routeTree.gen.tsis generated by the Vite plugin — never hand-edit.
Orientation
- Data layer — all platform API calls go through
data/fetchers.ts(openapi-fetch, typed by the generatedapi-typespackage) withhandleError; never rawfetch. One folder per resource indata/, most with akeys.tsquery-key factory. - State — valtio for global state (
state/), nuqs for URL state, react-hook-form + zod for forms. - Platform vs self-hosted —
IS_PLATFORMgates platform-only behavior;withAuthis a no-op when self-hosted. - Telemetry —
useTrack()fromlib/telemetry/track; event types live inpackages/common/telemetry-constants.ts. - Tests — default to including relevant tests with any change: a couple of unit tests for extracted logic, component tests for UI behavior, E2E only when the scope demands it (
studio-testinghas the decision tree). Not every PR needs them, but "no tests" should be a considered choice, not the default. Tooling: vitest + MSW; component tests usecustomRender+addAPIMockfromtests/lib/; unhandled network requests fail tests. Don'tvi.mock('@/data/...'). - Shortcuts — use the registry in
state/shortcuts/andcomponents/ui/Shortcut*.tsx; keepG then …chords for navigation; no one-off keyboard listeners (studio-shortcutshas the full pattern, including the search-input Escape handler). - Scoped PAT catalog —
packages/shared-data/scoped-access-token-permissions.tsfeeds Studio and the generated Personal Access Tokens guide. After changing it, runmake -C apps/docs/spec generate.partials.access-control; Docs Tests rejects stale tables. - Reuse first — before writing a new hook or helper, search for an existing one (
hooks/,lib/,packages/common,packages/ui-patterns). If you do need a new one, make it as reusable as possible: general naming, no page-specific coupling, placed where other callers can find it. - Co-locate sub-components with their parent; avoid barrel re-export files.
Code style
Older Studio code predates some of these conventions. For new or modified code, follow them rather than mirroring nearby legacy patterns:
-
Booleans read as
is/has/can/should. Derive them from existing state (const isFormValid = name.length > 0 && email.includes('@')) — mirroring a derivable value intouseStatesynced byuseEffectis a bug pattern. Give multi-condition logic a name (const canShowAddButton = !isSchemaLocked && canUpdateColumns && …) instead of inlining the chain in JSX. -
Ternaries: one is fine for a binary choice; never nest them. Anything bigger flattens — early returns in statement position, sibling
&&blocks in JSX. -
Fetch states render with early returns at the top level, or a flat
&&chain with mutually exclusive guards inline — never a nested ternary:// Top level: early return per state if (isLoading) return <GenericSkeletonLoader /> if (isError) return <AlertError error={error} subject="Failed to retrieve data" /> if (isSuccess && data.length === 0) return <EmptyState /> return <DataDisplay data={data} /> // Inline: flat `&&` blocks, mutually exclusive guards <div> {isLoading && <ShimmeringLoader />} {isError && <AlertError error={error} />} {isSuccess && data.length === 0 && <EmptyState />} {isSuccess && data.length > 0 && <DataDisplay data={data} />} </div> -
useEffectis for synchronizing with external systems (subscriptions, DOM, timers) — not for deriving data (compute it in render), reacting to user actions (do it in the handler), or fetching (React Query). Older code uses effects for all of these; don't copy it. -
State stays as local as possible — lift it only when it's actually shared. Related form fields belong in a single react-hook-form + zod form, not parallel
useStatecalls. -
Component size: split at ~200–300 lines — or sooner when a component grows multiple distinct UI sections, tangled conditional rendering, or clusters of unrelated
useState. Extract repeated JSX into small components, non-trivial pure logic into.utils.tsfunctions (which get unit tests), and reusable stateful logic into custom hooks. -
Memoization is not the default:
useMemo/useCallbackonly for measured expense or referential stability a memoized child depends on. -
TypeScript: avoid
ascasts — where external data enters, parse it with zod (schema.parse/safeParse) instead. Model multi-state values as discriminated unions ({ status: 'success'; data: T } | { status: 'error'; error: Error }) rather than independent boolean flags. -
Naming: prop callbacks are
onX, internal handlers arehandleX. Custom hooks return objects, not tuples. -
Refactoring: when you move or extract code into a new module, update every importer to point at the new location directly — do not leave a re-export shim in the old file "for backward compatibility." It's a one-line import change per consumer, and keeping shims around makes the codebase messy and the true source of a symbol ambiguous.
Defaults that differ here
- ESLint warnings are ratcheted in CI: the per-rule occurrence count must not increase, so a new
any, unresolvedexhaustive-depswarning, or default export fails the build even though it's "only a warning". Check locally withpnpm --filter studio run lint:ratchet. - Dead files and deps are gated in CI by knip (
pnpm knip --workspace apps/studiolocally). Framework-convention files nothing imports (routes, Vercel functions, TanStack Start files) belong inknip.jsoncunderworkspaces["apps/studio"].entry, notignore— an ignored file's imports aren't traced, so anything only it uses gets reported as dead. There's no inlineknip-ignorecomment; the only per-file opt-out is config. In a PR stack, a file whose first consumer lands in a later PR fails the gate on the earlier one — either add it in the same PR as its first use, or add it toworkspaces["apps/studio"].ignorewith aused from #NNNcomment and remove it in that PR. - Clipboard:
copyToClipboardfrom'ui', and neverawaitanything before calling it (Safari requires the write inside the user gesture; lint-enforced) — pass a Promise as the argument instead. useParams()comes from'common', notnext/navigation— it camelCases keys and returnsstring | undefined.- Permissions:
useAsyncCheckPermissionsfromhooks/misc/useCheckPermissions(returnscan: truewhen self-hosted). - Gating:
useIsFeatureEnabledfor product features,useFlagfrom'common'for feature flags — two different systems. - Dates:
dayjs(plugins pre-loaded at both entries,pages/_app.tsxandroutes/__root.tsx), notdate-fns. Toasts:toastfrom'sonner'. - Import split:
'ui'= primitives,'ui-patterns'= composed patterns (ConfirmationModal, …),@ui/*= alias intopackages/ui/src. Icons come fromlucide-react, always imported with theIconsuffix (import { CheckIcon, Trash2Icon } from 'lucide-react') — never the bare names (Check) orCheck as CheckIconaliases (lint-enforced). - New tables use
@tanstack/react-table;react-data-gridis banned for new code. - Ad-hoc SQL against the user's database goes through
executeSql/useExecuteSqlMutation(data/sql/execute-sql-mutation). - Confirmations:
ConfirmationModal/TextConfirmModalfromui-patterns, neverwindow.confirm. Disabled buttons needing an explanation useButtonTooltip; inline warnings useAdmonition.