A component — which folder it belongs in, how a form and a grid compose, how it is styled and translated, and the house style.
The failure is not the wrong folder; it is hand-rolling a shape the primitives already ship. A primitive owns colour, radius, focus ring, hover, disabled state and its data slots. A class that re-derives any of those means the wrong element is being rendered. Keep className to what the primitive cannot know: geometry, a one-off spacing, a grid span.
| The shape you are about to write | Render this |
|---|---|
| A bordered, hoverable row or tile with icon, title, sub-line | Item with its media, content, title, description and actions parts |
| A titled panel with header, body, footer | Card |
| A tinted square holding one icon | IconTile |
| A labelled metric | StatTile |
| "Nothing here yet" | Empty with its header, title and description. Never a bare paragraph. |
| A loading placeholder | Skeleton, geometry in the class only |
| A spinner | Spinner |
| Buttons that touch | ButtonGroup |
| An input with an affix, icon or inline button | InputGroup |
| A labelled control with description and error | Field |
| A status pill | Badge |
| A dialog or sheet | Modal, sized with its size prop, never a max-width class |
| A confirm-then-act dialog | ConfirmDialog |
Item is the one most often missed. An anchor or button carrying a border, a radius, padding and a hover fill is an Item. Read the primitive's file before composing; the variants sit at its top. Never guess a prop.
components/ui/: the generated primitives plus a few bespoke leaves, flat.ui-extended/: ports or extensions of a known archetype, and compounds built over ui/. The axis is archetype versus bespoke, not "extends a primitive". Nothing in ui/ imports upward.ui/ and hooks/ only when non-empty, that wraps children with context or chrome, or owns state, actions and UI across several files.Decide in that order. Nothing sits loose at the components/ root.
The form body is a client component rendering a form element. It takes no props for mode or data; it reads the form configuration from context.
"use client";
export function OrganizationForm() {
const t = useTranslations("organizations.form");
const formToast = useFormToast();
const config = useFormConfig();
const schema = createSchema((key) => t(`validation.${key}`));
const form = useForm<FormSchema>({
resolver: zodResolver(schema),
defaultValues: { ...defaultValues, ...(config.data ?? {}) },
disabled: config.isView,
});
const onSubmit = (addAnother = false) =>
form.handleSubmit((data) => submitAction(data, { addAnother }));
return (
<form onSubmit={onSubmit()}>
<FieldGroup>
<FieldSet>
<FormInput
control={form.control}
name="name"
label={t("fields.name")}
/>
</FieldSet>
<FormActions
isSubmitting={form.formState.isSubmitting}
isDirty={form.formState.isDirty}
onAddAnother={onSubmit(true)}
/>
</FieldGroup>
</form>
);
}Two traps. A form body throws outside its page or modal, because the configuration accessor has no fallback. And a field-level refine on a nullable field breaks resolver parity and only the app build catches it; use an object-level refine with an explicit path.
Do not hand-roll a dialog. Follow this convention.
Four files per entity:
<entity>-content.tsx, a server component. The page header, then the module settings server provider, the grid configuration provider with the persisted view state, and the grid. It awaits the grid's persisted cookies itself so first paint matches the last choice.<entity>-grid-shared.tsx, a client hook returning the filter definitions and sorts as plain data.<entity>-grid.tsx, the client router: form settings, view persistence, the form modal, then the filters provider, the filter sidebar, and the table or list variant.<entity>-grid-table.tsx and -grid-list.tsx, the variants.<AgGridInfiniteProvider
selection={selection}
pagination={ORGANIZATIONS_PAGINATION}
>
<GridContainer className="min-w-0 flex-1">
<InfiniteGridToolbar />
<div className="min-h-0 flex-1">
<AgGrid columnDefs={columnDefs} rowModelType="infinite" />
</div>
</GridContainer>
<GridDeleteDialog action={batchDeleteOrganizationAction} />
<GridForm namespace="organizations">
<OrganizationForm />
</GridForm>
</AgGridInfiniteProvider>The table runs the infinite row model over the keyset feed; the list runs a query-backed pagination with card rendering. The seam between them is intrinsic; do not unify. After a delete remove the rows, after an edit refresh the row, after a create or a filter change refresh the grid. The grid needs an unbroken min-h-0 flex-1 chain above it or it has zero height.
Definitions are authored once, up front, as data, because the provider needs the complete set before first render to seed URL deserialisation and server rendering. The default renderer draws one collapsible row per definition; a bespoke row references a definition by name and supplies its runtime data source.
<CollapsibleFilter filters={filterDefs} name="collection">
<FilterSelect items={collections} />
</CollapsibleFilter>The filters provider is the only entry to the filter context and requires a grid persistence provider above it. The controls carry no chrome, so the sidebar row and the pill bar compose the same ones, and single versus multi selection derives from the definition's type. When a table and a list both render the sidebar, mount the provider once in the parent that routes between them.
cn() merges class lists. Every chrome component takes className last and stamps a data slot.dark: variants. A component needing the resolved value reads the theme hook.No raw English in JSX, server actions, UI-surfaced errors or page metadata. A client component calls useTranslations(namespace), a server component awaits getTranslations(namespace), and metadata reads the meta namespace. Namespaces are one JSON file per language and namespace, and the file's content is the namespace body. The shared form copy is folded under every module's form node, so a domain form inherits common field, validation and action labels for free. Rebuild the messages package after editing JSON.
The sellable blocks, form, grid and filter, read copy through a resolver over a context instead, so they depend on no translation library.
function GridLegendListBody({ data, loading, error, empty }) {
if (loading) {
return <SkeletonGrid />;
}
if (error && data.length === 0) {
return <GridError />;
}
if (data.length === 0) {
return empty ?? <GridEmpty />;
}
return <LegendList data={data} />;
}| Symptom | Cause |
|---|---|
| Form crashes on mount | The body rendered outside its page or modal. |
| Filter sidebar crashes | A filters provider without a grid persistence provider above it. |
| Tab panel shows stale content | The tabs primitive never unmounts a panel. Make the tabs controlled and render only the active one. |
| Tree renders empty with no error | The tree port breaks under the compiler; opt the consumer out of memoisation. |
| Every toast fires twice, or none | The auth UI mounts its own toaster. Render one bare toaster per app root. |
| Modal renders nothing | The surface modal returns nothing in the page variant; the launcher must not mount under the route that is the surface. |
| Link inside a button misbehaves | Render the button as the link with the native-button flag off. |
| Grid has zero height | A broken min-h-0 flex-1 chain above it. |