resource()
Every option on a resource, its columns, fields and filters.
Register one table or model as an admin resource.
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; }>;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>: 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.
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
Prop
Type
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 theh1. 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'sdescriptionslot. Return a string.detail.badge(row)is a status pill beside the title:{ label, tone }, withtonefrom the sharedTonevocabulary.
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. 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:
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" }.
Prop
Type
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.
ColumnFormat
Declarative cell rendering — reach for this before writing a render function.
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:
{ 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.
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.
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.
Prop
Type
FieldType
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.
Prop
Type
FilterType
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.
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:
{
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.
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: db, session, dateRange, href,
labels and the per-request query memo, the same pieces a widget query gets.
Prop
Type
DetailTabContext
What DetailTab.render receives as its second argument.
Prop
Type
SelectOption
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.
Prop
Type
ResourceConfig
What resource() returns. You pass it to defineAdmin; you rarely read it.
Prop
Type