Browse documentation

Column renderers

Override how a single field renders in the table or drawer.

The lightest customization layer is L1: props. Hand a render function to any column and you control what that cell looks like — without touching the rest of the page.

A status badge

resource(schema.orders, {
  columns: [
    "id",
    {
      field: "status",
      render: (row) => (
        <span data-status={row.status} className="badge">
          {row.status}
        </span>
      ),
    },
    "total",
  ],
})

The column object accepts field (the row key to read from — typed against keyof Row) and render (a function returning a ReactNode). Other supported keys: label, sortable, width, align, className, hidden, pinnable, tone.

row is the typed record — schema.orders.$inferSelect for Drizzle, or your augmented model type for Prisma. Autocomplete and type-checking work end-to-end.

render runs server-side

render(row, ctx) is called on the server before the table reaches the client. FlowPanel ships the resulting ReactNode tree across the RSC boundary; the function itself never crosses. That means:

  • No client hooks inside renderuseState, useEffect, useContext, etc. only work inside a "use client" component.
  • No inline event handlersonClick, onChange, etc. cannot be attached directly inside render. Return an <a> / <Link> instead, or wrap the interactive part in a "use client" child that takes its data as plain props.
// src/admin/RefundButton.tsx
"use client";
export function RefundButton({ orderId }: { orderId: string }) {
  return <button onClick={() => fetch(`/api/refund/${orderId}`, { method: "POST" })}>
    Refund
  </button>;
}

// flowpanel.config.ts
resource(schema.orders, {
  columns: [
    "id",
    {
      field: "status",
      // Server-side: return the element tree. The interactivity lives in
      // RefundButton (a "use client" component).
      render: (row) => <RefundButton orderId={row.id} />,
    },
  ],
});

Joined or computed values

ColumnDef.field is keyof Row & string, so dotted relation paths aren't supported as column identifiers. To display a value from a joined record, drop the field and provide a render that pulls from the row directly (Drizzle relation queries) or fans out via your own loader:

{
  label: "Customer",
  render: (row) => row.customerEmail ?? "—",
}

Default labels

If you omit label, FlowPanel humanizes the raw field name — createdAt becomes "Created at", apiKey becomes "API key", common initialisms (ID, URL, API, ...) stay uppercase. The same helper is exported as humanize(name) and resolveFieldLabel(label, field) from @flowpanel/react.

When to reach for theme slots

If you find yourself overriding every column's renderer, or reaching for behavior the column API doesn't expose, that's the signal to step up to L2: theme slots — swap one of the 10 component primitives (see Theme slots). If even that's not enough, L3: eject writes the page's source into your repo.