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

```ts
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 `render` — `useState`, `useEffect`,
  `useContext`, etc. only work inside a `"use client"` component.
- **No inline event handlers** — `onClick`, `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.

```tsx
// 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:

```ts
{
  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](/docs/customization/theme-slots)). If even that's
not enough, **L3: eject** writes the page's source into your repo.
