---
title: Tables
sidebarTitle: Tables
description: Add, change, or remove columns on built-in list tables (products, orders, customers, …), or define a table for your own resource.
---

> **NOTE:** Examples here import from `@spree/dashboard`, which re-exports the framework and the design system for host applications. A **distributed plugin** imports `@spree/dashboard-core` and `@spree/dashboard-ui` directly instead — it extends the shell rather than shipping it. See [plugin overview](../plugins/overview.md).

Every list page in the dashboard renders through `<ResourceTable>`, which reads its column definitions from a **table registry** keyed by table name (`products`, `orders`, `customers`, …). That gives you two levers:

1. **Extend a built-in table** — add, patch, or remove columns (including their filters) without forking the page.
2. **Define your own table** — declare columns once with `defineTable`, then render them with `<ResourceTable>` on your own page.

## Add a column to a built-in table

Imperatively, from `src/plugins.ts` (or a plugin entry):

```tsx
import { tables } from '@spree/dashboard'

tables.products.addColumn({
  key: 'brand',
  label: 'Brand',
  default: true,                          // visible out of the box
  render: (product) => product.brand?.name ?? '—',
})
```

Or declaratively through `defineDashboardPlugin` — same effect, grouped with your other extensions:

```tsx
defineDashboardPlugin({
  tables: {
    products: {
      add: [{ key: 'brand', label: 'Brand', default: true, render: (p) => p.brand?.name ?? '—' }],
    },
  },
})
```

New columns append after the existing ones. Column keys must be unique per table — adding a duplicate throws with a message telling you to use `updateColumn` instead.

> **NOTE:** **Timing is taken care of.** Built-in tables register when their page first loads, which may be after your code runs. Mutations against a table that isn't registered yet are queued and replayed the moment it appears — and mutations against a table that never registers simply never fire. That means extending an optional feature's table is safe even when that feature isn't installed.

## Modify or remove built-in columns

```tsx
tables.products.updateColumn('price', { label: 'Retail price' })
tables.products.removeColumn('cost_price')
```

Declaratively:

```tsx
defineDashboardPlugin({
  tables: {
    products: {
      update: { price: { label: 'Retail price' } },
      remove: ['cost_price'],
    },
  },
})
```

`updateColumn` patches shallowly — pass only the fields you want to change. `removeColumn` of an unknown key is a no-op.

## Column definition

```ts
interface ColumnDef<T> {
  key: string                       // unique per table; also the default Ransack sort/filter attribute
  label: string                     // header text — use i18n.t(...) for translated labels
  render?: (row: T) => ReactNode    // cell renderer; defaults to rendering row[key] as text
  className?: string                // cell class (e.g. 'text-right tabular-nums')
  default?: boolean                 // part of the visible column set out of the box
  sortable?: boolean                // header toggles Ransack sort on the attribute
  filterable?: boolean              // appears in the filter panel
  ransackAttribute?: string         // backend attribute when it differs from key
  displayable?: boolean             // set false for filter-only entries (no column rendered)
}
```

### Filter types

When a column is `filterable`, `filterType` decides which filter UI it gets:

| `filterType` | Filter UI | Extra fields |
|---|---|---|
| `'string'` (default) | Text input with contains/equals operators | — |
| `'boolean'` | Yes/no toggle | — |
| `'number'`, `'currency'` | Numeric comparisons | — |
| `'date'` | Date range picker (store-timezone aware) | — |
| `'enum'` | Fixed option list | `filterOptions: [{ value, label }]` (required) |
| `'resource'` | Multi-select autocomplete against another resource | `filterResource` (required) — `queryKey`, `search`, `hydrate`, `getOptionLabel` |
| `'tags'` | Tag autocomplete | `taggableType` (required), e.g. `'Spree::Product'` |

A `'resource'` filter searches as the admin types and resolves already-selected IDs back to labels on page reload:

```tsx
tables.products.addColumn({
  key: 'brand_id',
  label: 'Brand',
  displayable: false,          // filter only — no column in the table
  filterable: true,
  filterType: 'resource',
  filterResource: {
    queryKey: 'brands',
    search: (query) => brandsClient.list({ q: { name_cont: query } }),
    hydrate: (ids) => brandsClient.list({ q: { id_in: ids } }),
    getOptionLabel: (brand) => brand.name,
  },
})
```

Filtering and sorting are executed by the Admin API (Ransack), not in the browser — `key` (or `ransackAttribute`) must be a filterable/sortable attribute the API allows.

## Define your own table

For your own list page, declare the table once — typically right next to your `defineDashboardPlugin` call:

```tsx
import { defineTable } from '@spree/dashboard'

defineTable<Brand>('brands', {
  title: 'Brands',
  searchParam: 'search',
  searchPlaceholder: 'Search by name…',
  defaultSort: { field: 'name', direction: 'asc' },
  emptyMessage: 'No brands yet',
  columns: [
    { key: 'name', label: 'Name', sortable: true, filterable: true, default: true },
    { key: 'products_count', label: 'Products', default: true, className: 'text-right tabular-nums' },
    { key: 'created_at', label: 'Created', sortable: true, default: false },
  ],
})
```

Then render it from your page component:

```tsx
<ResourceTable<Brand>
  tableKey="brands"          // which table definition to read
  queryKey="brands"          // TanStack Query cache key
  queryFn={(params) => brandsClient.list(params)}
  searchParams={searchParams}
/>
```

`<ResourceTable>` handles pagination, sorting, the filter panel, column visibility, and URL round-tripping of all of it. See [Routes](routes.md) for the page around it — the [example Brands plugin](https://github.com/spree/spree/tree/main/packages/dashboard-plugin-example) shows the complete wiring.

## Bulk actions, row actions, reordering

These are **props on `<ResourceTable>`**, composed by the page that renders the table:

```tsx
<ResourceTable<Brand>
  // …
  bulkActions={[{ key: 'archive', label: 'Archive selected', onSelect: async (rows) => { /* … */ } }]}
  rowActions={(row) => <BrandRowMenu brand={row} />}
/>
```

Use them freely on your own pages. They are **not registry-driven** — a plugin can't inject bulk or row actions into a built-in page's table today. If a built-in page needs an injection point, that's what [slots](slots.md) are for.

## Reference

- [`table-registry.ts`](https://github.com/spree/spree/blob/main/packages/dashboard-core/src/lib/table-registry.ts) — `defineTable`, `tables`, the full `ColumnDef` union
- [`<ResourceTable>`](https://github.com/spree/spree/blob/main/packages/dashboard-core/src/components/resource-table.tsx) — props for bulk actions, row actions, drag-reordering
- [Example Brands plugin](https://github.com/spree/spree/tree/main/packages/dashboard-plugin-example) — a real `defineTable` + `<ResourceTable>` + products-column extension
