Register one table or model as an admin resource.

```ts
export function resource<Ref, const Name extends string>(ref: Ref, options: ResourceOptions<InferRow<Ref>> & { name: Name; }): ResourceConfig<Ref, InferRow<Ref>, ResourceOptions<InferRow<Ref>> & { name: Name; }>;
```

```ts
export function resource<Ref>(ref: Ref, options: ResourceOptions<InferRow<Ref>>): ResourceConfig<Ref, InferRow<Ref>, ResourceOptions<InferRow<Ref>>>;
```

`ref` is whatever your adapter understands — a Drizzle table object, or a
Prisma model name. The row type comes from
[`InferRow<Ref>`](/docs/reference/registry#inferrow): a Drizzle table resolves
through `$inferSelect`, a string ref through your `FlowpanelResources`
augmentation, and anything else falls back to `Record<string, unknown>`. When
it resolves to a real row type, `columns`, `search`, `filters` and
`defaultSort` only accept real column names; when it falls back, they are
plain `string` and the check moves to
[`defineAdmin`'s introspect-time validation](/docs/build/resources#typos-and-column-validation).

```ts excerpt
import { resource } from "@flowpanel/kit";
import { orders } from "@/db/schema";

export const ordersResource = resource(orders, {
  label: "Order",
  plural: "Orders",
  icon: "shopping-cart",
  columns: ["id", "customerEmail", { field: "total", format: "money" }, "status"],
  search: ["customerEmail"],
  filters: ["status", { field: "createdAt", type: "daterange" }],
  defaultSort: { field: "createdAt", dir: "desc" },
  detail: { header: (row) => `Order · ${row.customerEmail}` },
});
```

## ResourceOptions

**ResourceOptions**
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | `string` | no | URL segment and registry key. Defaults to the adapter's table name. |
| `label` | `string` | no | Singular label shown in headings and buttons. |
| `labelOne` | `string` | no | Label for exactly one row, e.g. `"Customer"`. Used where a heading talks
about a single record — the create page's title. Falls back to `label`,
then to the resource name. |
| `plural` | `string` | no | Plural label shown in the nav and list title. |
| `icon` | `IconName` | no | Serializable Lucide icon rendered in navigation and the command palette. |
| `hidden` | `boolean` | no | Keep the resource out of the navigation — its routes still work. |
| `expose` | `array` | no | Additional fields shared by list and detail presentation. Declare render
dependencies explicitly; callbacks never receive an undeclared DB row. |
| `columns` | `array` | no | Columns of the list table, in order. A bare string is a field name.
Omitted, `defineAdmin` fills it with every column the adapter introspects,
in introspection order. |
| `search` | `array` | no | Fields the search box queries. Without this, search is disabled. |
| `filters` | `array` | no | Filter controls above the list. A bare string always renders a text filter — declare a `FilterDef` for any other control. |
| `defaultSort` | `object` | no | Sort applied when the URL carries none. |
| `pageSize` | `number` | no | Rows per page. |
| `density` | `"compact" \| "comfortable"` | no | Row height preset for the list table. |
| `rowClick` | `"drawer" \| "detail" \| false` | no | What a row click — and Enter on the focused row — does. `"detail"` navigates
to the row's own page, `"drawer"` opens the drawer and requires `drawer`, and
`false` leaves rows inert. A row the projection cannot identify never acts. |
| `rowKey` | `string & keyof Row` | no | Property holding each row's unique id. |
| `drawer` | `object` | no | Side panel opened for a single row. |
| `detail` | `object` | no | Full-page view at `/<basePath>/<resource>/<id>`. |
| `schema` | `object` | no | Validation for writes. Overrides the schema inferred from the adapter. |
| `create` | `object` | no | Create form. `disabled` removes the route, not just the button. |
| `update` | `object` | no | Edit form. `disabled` removes the route, not just the button. |
| `delete` | `object` | no | Delete behaviour. Set `softDelete` to a timestamp column to keep rows recoverable. |
| `actions` | `array` | no | Per-row actions, rendered inline or in the row's menu. |
| `bulkActions` | `array` | no | Actions applied to a selection of rows. |
| `scope` | `function \| "bypass"` | no | How the admin-wide `scope` narrows this resource. `"bypass"` opts out explicitly. |
| `access` | `object` | no | Canonical operation-level authorization. |
| `fieldAccess` | `Partial<object>` | no | Canonical read/write policy for declared fields. |
| `requireRole` | `function \| array \| string` | no |  |
| `export` | `object \| false` | no | Export button. `false` removes it. Defaults to CSV and JSON of the visible columns. |
| `import` | `object \| false` | no | Import button. Off unless set; rows are created through the normal write path. |
| `audit` | `boolean` | no | Opt this resource out of the admin-wide audit sink. |
| `realtime` | `boolean \| string` | no | Refresh the list when the channel fires. `true` uses `resource.<name>`. |
| `views` | `array` | no | Filter / sort presets offered as a dropdown above the list. |
| `empty` | `object` | no | What the list shows when there are no rows at all. |

### Detail headings and editing

Three callbacks fill the detail page's header, and each one receives the same
authorized row projection as detail tabs: declared readable fields plus permitted
`detail.expose` fields, never undeclared adapter fields and never fields denied by
`fieldAccess`.

- `detail.title(row)` is the content of the `h1`. Return text or inline React
  elements, not another heading or a complete page header.
- `detail.subtitle(row)` is the line under it, rendered through the header's
  `description` slot. Return a string.
- `detail.badge(row)` is a status pill beside the title: `{ label, tone }`, with
  `tone` from the shared `Tone` vocabulary.

```ts excerpt
detail: {
  title: (row) => row.email,
  subtitle: (row) => `Customer since ${row.createdAt}`,
  badge: (row) => (row.churned ? { label: "Churned", tone: "warn" } : null),
}
```

Return `null` or `undefined` from any of them and that slot is simply absent; a
nullish `title` falls back to the resource's singular label and the readable row
identifier. `detail.header` still works and still wins nothing: `title` takes
precedence when both are declared, and `header` is deprecated for removal in 1.0.

The generated edit action sits beside the heading; it is omitted when
`update.disabled` is true or the resource's update access policy denies the
request, including asynchronous policies. Set `labels.actions.edit` to translate
its text. The edit route enforces its own authorization as well. At narrow widths
the actions wrap below the title rather than squeezing it.

Custom `PageHeader` slots should use the exported `PageHeaderProps` type: `title`
accepts React content as well as strings, and `badge` takes the same
`{ label, tone }` object.

Without `detail.tabs`, the page renders one key/value block. `detail.fields`
chooses and orders the fields in it; omitted, every readable declared field shows.

### Opening a row

`rowClick` decides what a click on a row — and Enter on the focused row — does.

| Value | Effect |
| --- | --- |
| `"detail"` | Navigates to the row's own page under the admin's `basePath`. |
| `"drawer"` | Opens the [drawer](/docs/reference/drawer). Requires a `drawer` config; `defineAdmin` refuses the combination without one. |
| `false` | Rows render, but a click does nothing. |

Omitted, it follows the resource: `"detail"` when `detail` is configured and
`drawer` is not, `"drawer"` when only a drawer is, and `false` when neither is.
An explicit value always wins, so `rowClick: "detail"` on a resource that also
has a drawer sends the click to the page and leaves the drawer to its own
affordances.

A row the projection could not identify never navigates — the row identity rule
applies to the keyboard exactly as it does to the pointer, and the table's
"Enter opens" hint (`labels.table.rowsHint`) is announced only when some row on
the page can be opened. The mobile card list follows the same rule.

Navigation is a router push on the row, not a link: cmd-click, middle-click and
"open in new tab" do not open a second tab from a row. Reach the same page as a
real link through the detail page's own URL, or add a column whose `render`
returns an anchor.

### Icons

`ResourceOptions.icon`, dashboard/page/queue icons, action icons and
`CommandItem.icon` all use the same serializable `IconName` union. FlowPanel
turns the name into a decorative Lucide SVG in the sidebar, tab strip and ⌘K
palette, so the config remains safe to pass through a React Server Component
boundary. Unknown names are rejected by TypeScript instead of disappearing at
runtime. Built-in names include `users`, `settings`, `layout-dashboard`,
`database`, `shopping-cart`, `archive`, `refresh`, `play`, `ban` and
`trash-2`; editor autocomplete shows the complete `IconName` set.

For custom pages and slots, render the same registry directly:

```tsx excerpt
import { FlowpanelIcon } from "@flowpanel/kit/react";

<FlowpanelIcon name="workflow" className="size-4" />;
```

## ColumnDef

An entry in `columns`. A bare string is shorthand for `{ field: "name" }`.

**ColumnDef**
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `field` | `string & keyof Row` | no | Row property this column reads. Omit only when `render` supplies the cell. |
| `label` | `string` | no | Header text. Defaults to a humanized `field`. |
| `render` | `function` | no | Custom cell renderer. Runs on the server; receives the request context. |
| `sortable` | `boolean` | no | Allow clicking the header to sort by this column. |
| `width` | `number \| string` | no | Fixed column width, in px when a number. |
| `align` | `"right" \| "center" \| "left"` | no | Horizontal cell alignment. Defaults to `"left"`. |
| `className` | `string` | no | Extra classes on every cell in this column. |
| `hidden` | `boolean` | no | Keep the column out of the table (still available to export and drawer). |
| `format` | `ColumnFormat` | no | Declarative cell formatting — use instead of `render` where it fits. |
| `reference` | `object` | no | Resolve a foreign key to a label from another resource. |
| `editable` | `boolean` | no | Allow editing this cell inline from the list. |

`field` is optional so a column can render a value no row property holds — a
join, something computed. Such a column must set both `render` and `label`,
never sorts or filters, and is skipped by export; see
[Joined or computed values](/docs/customization/column-renderers#joined-or-computed-values).

### ColumnFormat

Declarative cell rendering — reach for this before writing a `render` function.

```ts excerpt
type ColumnFormat =
  | "badge"
  | "money"
  | "number"
  | { kind: "badge"; tones?: Record<string, Tone> }
  | { kind: "money"; currency?: string; scale?: number };
```

`Tone` is `"default" | "accent" | "ok" | "warn" | "err" | "info" | "muted"`.
The object form of `badge` maps each cell value to a tone:

```ts excerpt
{ field: "status", format: { kind: "badge", tones: { paid: "ok", failed: "err" } } }
```

### Formatting values yourself

Two formatters back every cell, card and export, and both take the admin's
resolved `formatting`, so locale, currency and time zone come from one place.

Format the `money` / `number` variants of {@link ColumnFormat} as a string.
Server- and client-rendered cells share it so the same value reads the same
on both; the `badge` variant is JSX and stays with each renderer.

```ts
export function formatColumnValue(value: unknown, format: ColumnFormat, formatting?: ResolvedFormatting): string;
```

Format the `NumericFormat` variants a widget declares. Locale and currency both
come from the admin's resolved `formatting`, so one dashboard never prints two
currencies for the same money. A bare locale string is accepted as the 0.2
signature and keeps that release's currency and zone.

```ts
export function formatNumber(value: number, format?: NumericFormat, formatting?: string | ResolvedFormatting): string;
```

`formatColumnValue` renders the string variants of `ColumnFormat`; `formatNumber`
renders a widget's `NumericFormat` (`number`, `currency`, `percent`, `bytes`,
`duration`). Both live in `@flowpanel/core`, so a server component may call them —
`@flowpanel/react` only re-exports `formatNumber` for client code.

## FieldDef

An entry in `create.fields`, `update.fields`, a `DetailTab`, or an action's
`form`. Every rule here is enforced on the server, not just in the UI.

**FieldDef**
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | `string & keyof Row` | yes | Row property this field writes. |
| `label` | `string` | no | Field label. Defaults to a humanized `name`. |
| `help` | `string` | no | Hint rendered under the control. |
| `placeholder` | `string` | no | Placeholder text for empty inputs. |
| `type` | `FieldType` | no | Which control to render. Omitted, a declared field renders as `"text"` —
or `"reference"` when `reference` is set. The adapter's column type is
consulted only by the fully generated form a resource gets when it declares
no `create.fields` / `update.fields` at all, and only maps number, boolean,
date and json; every other column type renders as text there too. |
| `options` | `function \| array` | no | Choices for `select` / `multiselect` / `radio`. A function resolves per request. |
| `reference` | `object` | no | Turn the field into a searchable picker over another resource. |
| `required` | `boolean` | no | Mark the field required in the form. Server-side validation still comes from `schema`. |
| `readOnly` | `function \| boolean` | no | Render non-editable. Enforced server-side, not just in the UI. |
| `hidden` | `function \| boolean` | no | Leave the field out of the form entirely. |
| `requireRole` | `function \| array \| string` | no | Restrict who may see and write this field. Enforced on every write route. |
| `validate` | `function \| object` | no | Per-field validation, run server-side after the resource schema. |
| `defaultValue` | `unknown` | no | Value used when creating, for keys the operator left empty. |
| `span` | `12 \| 6 \| 4 \| 3 \| 2 \| 1` | no | Width in a 12-column form grid. Defaults to full width. |
| `group` | `string` | no | Fieldset heading this field is grouped under. |

### FieldType

```ts excerpt
type FieldType =
  | "text" | "textarea" | "number" | "email" | "password" | "url"
  | "date" | "datetime" | "time"
  | "boolean" | "switch" | "checkbox"
  | "select" | "multiselect" | "radio"
  | "json" | "markdown" | "tags" | "reference" | "hidden" | "color";
```

Omit it on a declared field and the control is `"text"` — or `"reference"`
when the field sets `reference`. The adapter's `ColumnMeta.type` is consulted
only by the fully generated form a resource gets when it declares **no**
`create.fields` / `update.fields` at all, and even there it only maps
`number`, `boolean`, `date` and `json`; every other column type renders as
text. So `enum`, `array` and `reference` columns need an explicit `type` (plus
`options` / `reference`) to get a richer control.

## FilterDef

An entry in `filters`. A bare string is **always** a `text` filter — there is
no inference from the column's type, so declare a `FilterDef` with an explicit
`type` for a select, a date range, or anything else.

**FilterDef**
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `field` | `string & keyof Row` | yes | Row property this filter narrows. |
| `label` | `string` | no | Control label. Defaults to a humanized `field`. |
| `type` | `FilterType` | yes | Which control to render, and how the value is encoded in the URL. |
| `options` | `function \| array` | no | Choices for `select` / `multiselect`. A function is resolved per request. |
| `defaultValue` | `unknown` | no | Applied until the operator picks a value of their own. |
| `hidden` | `boolean` | no | Apply the filter without showing a control for it. |

### FilterType

```ts excerpt
type FilterType =
  | "text" | "select" | "multiselect"
  | "daterange" | "numeric-range" | "boolean" | "tag";
```

`daterange` and `numeric-range` reach your adapter as a `FilterRangeValue`;
`multiselect` as a `FilterInValue`. Both are documented under
[Contexts](/docs/reference/contexts#filter-values).

## DetailTab

A tab on the full-page detail view at `/<basePath>/<resource>/<id>`.

The tab in view is the `?tab=<key>` search parameter — a real query parameter the
server reads, not a URL fragment — so a tab is linkable, bookmarkable and survives
a reload. Only the selected tab runs on the server; the others render their label
and nothing else.

A tab declares exactly one kind of content, and they resolve in this order:
`render`, `widgets`, `resource`, then fields.

### Fields and sections

`fields` renders a flat key/value view; `"*"` shows every readable field. Each
entry is a bare field name or a `FieldDef`, whose `label` overrides the column
header the list page uses. Column `format` and `render` from `columns` apply here
too, so a value reads the same on both pages.

`sections` groups those fields into titled blocks and wins over `fields` on the
same tab:

```ts excerpt
{
  key: "overview",
  label: "Overview",
  sections: [
    { label: "Identity", fields: ["email", "name"] },
    { label: "Billing", fields: [{ name: "mrr", label: "MRR" }, "plan"] },
  ],
}
```

Fields named by `fields` or by any `sections` entry are declared dependencies:
they are added to the tab's projection and loaded when that tab is the one in
view. A tab that ends up with nothing the reader may see shows
`labels.detail.noFields`.

### Widgets

`widgets` puts dashboard widgets in a detail tab, in the dashboard's own grid —
see [widgets in detail tabs](/docs/build/dashboards#widgets-in-detail-tabs).
`columns` sets the grid width, defaulting to 2. Each widget's `ctx.row` is the
record being viewed, and each one renders inside its own error boundary, so a
query that fails costs you that card and not the page. Declaring `widgets: []` is
still a widgets tab — it renders empty rather than falling through to the other
modes, because an empty array is a typo, not a choice.

### Related rows

A tab with a `resource` renders that resource's list columns and paginates under
its own URL key `relatedPage.<key>` — so two tabs page independently and Back,
Forward and reload restore the page the reader was on. Reference columns resolve
to the referenced row's label, exactly as they do on a list page and in a table
widget.

`hide` drops columns before anything is rendered, and the hidden fields are also
projected out of the rows sent to the browser; the parent foreign key is the usual
candidate, since every row in the tab shares it. The tab's own row key is always
kept, since the table needs it for row identity. A hidden column cannot be sorted
from the URL either.

An "Open list →" link appears only when it would tell the truth: every filter
value has to be a scalar (`string`, `number`, `boolean` or `bigint`) and every
filter field has to be one the target resource declares, since the list page
accepts `?f_<field>=<value>` on no other terms. A tab filtered on a range, an `in`
set, or an undeclared field keeps its rows and drops the link. `sort` sets the tab's
default order, falling back to the target's `defaultSort` and then to its primary
key. Clicking a header re-sorts through the URL key `relatedSort.<key>`, written
as `<field>.<asc|desc>` and accepted only for a visible column the target
resource really has. Above the table, an "Open list →" link
(`labels.related.openList`) leads to the target's own list page with the
relationship already applied as `?f_<field>=<value>`, so the full paginated list
is one click away.

The tab's `filter` is a constraint, not a refinement: an undefined parent value or
a field the reader may not read yields no rows and no query.

### Custom content

`render(row, ctx)` returns whatever you like. `row` is the projected row — the
declared and `detail.expose` fields, nothing else — and `ctx` is a
[`DetailTabContext`](#detailtabcontext): `db`, `session`, `dateRange`, `href`,
`labels` and the per-request `query` memo, the same pieces a widget query gets.

**DetailTab**
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `key` | `string` | yes | Stable identifier, stored in the URL's `?tab=` search parameter. |
| `label` | `string` | yes | Tab label. |
| `hidden` | `function` | no | Hide the tab for rows that should not show it. |
| `fields` | `"*" \| array` | no | Field list to render as a key/value view. `"*"` shows every column. |
| `sections` | `array` | no | Grouped key/value fields, one block per entry. Wins over `fields`. |
| `resource` | `string` | no | Render rows of a related resource instead of fields. |
| `filter` | `function` | no | Filter applied to `resource`, derived from the row being viewed. |
| `hide` | `array` | no | Related-tab columns to omit, typically the parent foreign key. |
| `sort` | `object` | no | Related-tab default sort; falls back to the target's `defaultSort`, then its primary key. |
| `widgets` | `array` | no | Dashboard widgets with `ctx.row` set to the record being viewed. |
| `columns` | `12 \| 6 \| 4 \| 3 \| 2 \| 1` | no | Grid columns for `widgets`. |
| `render` | `function` | no | Render arbitrary content instead of fields, widgets or a related resource. |

## DetailTabContext

What `DetailTab.render` receives as its second argument.

**DetailTabContext** — What a `DetailTab.render` callback is handed alongside the row: the widget
context minus the parts a detail tab has no business with.
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `db` | `unknown` | yes | Your database client, exactly as handed to the adapter. |
| `session` | `Session \| null` | yes |  |
| `dateRange` | `object` | yes | Range chosen in the dashboard's date picker, already resolved to dates. |
| `href` | `function` | yes | Mount-aware link builder; never hand-type `"/admin/…"` in a config. |
| `labels` | `object` | yes | The admin's resolved chrome strings, so a widget never hard-codes English. |
| `query` | `function` | yes | Memoises `fn` for the current request so two widgets share one query. A
failure is memoised too — the request does not retry under the same key. |
| `sql` | `function` | yes | One read-only statement, as a tagged template. Every interpolated value is
bound as a parameter, so `${input}` can never change the statement.
`Date` binds as an ISO-8601 string and `bigint` as decimal text. Throws when
the configured adapter implements no `sql` capability.

Runs exactly the statement you write: no tenant scope, no access rule and no
soft-delete filter are applied. Add those predicates yourself, or use
`count` and resource reads, which do. |
| `count` | `function` | yes | How many rows of a registered resource match `where`, through the same role,
access and tenant-scope checks as any other cross-resource read. A resource
the current session may not read counts `0`. |

## SelectOption

```ts excerpt
type SelectOption = { label: string; value: string | number | boolean };
```

Wherever `options` is accepted, you may pass `string[]`, `SelectOption[]`, or a
function resolved per request.

## ListResult

What a `list` returns, from the adapter through to the pagination control.

**ListResult** — One page of rows, as returned by `Adapter.list`.
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `rows` | `array` | yes |  |
| `total` | `number` | yes | Total matching rows across every page — drives the pagination control. |
| `page` | `number` | yes | 1-based index of the page in `rows`. |
| `pageSize` | `number` | yes |  |

## ResourceConfig

What `resource()` returns. You pass it to `defineAdmin`; you rarely read it.

**ResourceConfig**
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `__kind` | `"resource"` | yes |  |
| `ref` | `Ref` | yes |  |
| `options` | `Options & object` | yes |  |
