Customising admin tables (incl. column packing)

Formats, badges, filters, and the new cell() render — fit 5 fields into 2 columns.

12 minmedium

The table is the first thing operators see — the column choices, the formatting, the empty state. The generator gets it 80% right; this lesson covers the last 20%. By the end you can pick which columns show, format values as money/relative dates/badges, pack two fields into one column, hide noisy columns, and add server-side filters that drive a real CRM-style list.

Where the table lives

Same file as the form — apps/admin/resources/<plural>.ts — under the table key:

apps/admin/resources/contacts.ts (excerpt)
table: {
columns: [
{ key: "name", label: "Name", sortable: true, searchable: true },
{ key: "email", label: "Email", sortable: true, searchable: true },
{ key: "phone", label: "Phone" },
{ key: "group.name", label: "Group" },
{ key: "created_at", label: "Created", sortable: true, format: "relative" },
],
filters: [],
defaultSort: { key: "created_at", direction: "desc" },
searchable: true,
pageSize: 20,
},

Every column accepts these keys:

KeyWhat it does
keyField name. Supports dotted paths (group.name) for preloaded relations.
labelColumn header.
sortableAdds clickable sort arrows (server-side sort).
searchableIncludes this column in the global search box.
hiddenDefined but not shown — handy for columns you want filterable but invisible.
widthCSS width — keeps narrow columns narrow ("80px", "15%").
formatPre-built renderer (see table below).
badgeFor status-style columns — pairs with format: "badge".
currencyPrefixFor format: "currency" — e.g. "$", "€".
classNameExtra Tailwind classes on the cell.
cellv3.31.15+ — custom render function. Receives the full row. Overrides format/badge.

Built-in column formats

// 13 ready-made cell renderers
{ key: "name", format: "text" } // default
{ key: "is_active", format: "boolean" } // green check / grey dash
{ key: "status", format: "badge", badge: { active: { color: "success", label: "Active" },} }
{ key: "price", format: "currency", currencyPrefix: "$" }
{ key: "starts_at", format: "date" } // 2026-06-21
{ key: "created_at", format: "relative" } // "5 minutes ago"
{ key: "avatar", format: "image" } // small thumbnail
{ key: "video_url", format: "video" } // play icon link
{ key: "website", format: "link" } // external link with icon
{ key: "email", format: "email" } // mailto: link
{ key: "color", format: "color" } // colored swatch
{ key: "body", format: "richtext" } // stripped of HTML for preview
{ key: "user", format: "user" } // avatar + name combo

Recipe 1 — hide the noisy timestamp from the default list

The generator adds created_at automatically. For a contacts list you might prefer just last-seen or to drop it entirely:

columns: [
{ key: "name", label: "Name", sortable: true, searchable: true },
{ key: "email", label: "Email", sortable: true, searchable: true },
{ key: "phone", label: "Phone" },
{ key: "group.name", label: "Group" },
// dropped created_at
],

Recipe 2 — money + percent + relative date

columns: [
{ key: "title", label: "Project", sortable: true, searchable: true },
{ key: "budget", label: "Budget", format: "currency", currencyPrefix: "$" },
{ key: "progress", label: "Progress", format: "text",
cell: (row) => <span>{Math.round(Number(row.progress) * 100)}%</span> },
{ key: "due_date", label: "Due", format: "relative", sortable: true },
],

Recipe 3 — status badge with colour coding

{
key: "status",
label: "Status",
format: "badge",
badge: {
active: { color: "success", label: "Active" },
inactive: { color: "muted", label: "Inactive" },
pending: { color: "warning", label: "Pending" },
archived: { color: "danger", label: "Archived" },
},
}

Recipe 4 — pack multiple fields into one column

The big one. Sometimes "Name" really means "Name + email stacked". Sometimes price + currency should be one cell, not two. As of v3.31.15, the cell property accepts a render function that receives the entire row — pack as many fields as you like:

apps/admin/resources/contacts.ts
columns: [
{
key: "name",
label: "Contact",
sortable: true,
searchable: true,
cell: (row) => (
<div className="flex flex-col">
<span className="font-medium text-foreground">{String(row.name)}</span>
<span className="text-xs text-text-muted">{String(row.email)}</span>
</div>
),
},
{
key: "phone",
label: "Contact info",
cell: (row) => (
<div className="flex flex-col">
<span className="text-sm">{String(row.phone ?? "—")}</span>
<span className="text-xs text-text-muted">{String((row.group as { name?: string })?.name ?? "no group")}</span>
</div>
),
},
{ key: "created_at", label: "Created", format: "relative", sortable: true },
],

Five fields collapse into two columns. The cell function gets the full row (typed as Record<string, unknown>) so dotted keys aren't needed — read whatever properties make sense.

When cell is set, it replaces the entire cell rendering — format, badge, and currencyPrefix are ignored. The key still drives sorting + search, so put the most meaningful sort field there (usually name for the leftmost packed column).

Recipe 5 — three-column dashboard look in a flat table

A pattern from CRMs: avatar + name + email in column 1, company + role in column 2, last activity in column 3. Three packed columns instead of seven thin ones:

columns: [
{
key: "name",
label: "Person",
cell: (row) => (
<div className="flex items-center gap-3">
<img src={String(row.avatar ?? "/default-avatar.png")} className="h-9 w-9 rounded-full" />
<div className="flex flex-col">
<span className="font-medium">{String(row.name)}</span>
<span className="text-xs text-text-muted">{String(row.email)}</span>
</div>
</div>
),
},
{
key: "company",
label: "Company",
cell: (row) => (
<div className="flex flex-col">
<span>{String(row.company)}</span>
<span className="text-xs text-text-muted">{String(row.role)}</span>
</div>
),
},
{
key: "last_activity_at",
label: "Last activity",
format: "relative",
sortable: true,
},
],

Filters — server-side query knobs

Filters add UI controls that translate to query-string params on GET /api/<plural>. The API's generated List handler already understands ?group_id=…, ?created_at_from=…, etc.

filters: [
{
key: "status",
label: "Status",
type: "select",
options: [
{ label: "Active", value: "active" },
{ label: "Inactive", value: "inactive" },
],
},
{
key: "group_id",
label: "Group",
type: "select",
options: [/* populate at runtime from a fetch */],
},
{
key: "created_at",
label: "Created date",
type: "date-range",
},
{
key: "is_active",
label: "Active only",
type: "boolean",
},
],

Default sort, page size, search

table: {
columns: [/* … */],
defaultSort: { key: "created_at", direction: "desc" },
pageSize: 20, // 20 by default. 10/50/100 are nice round options.
searchable: true, // shows the global search box. Searches all searchable: true columns.
},

Hidden / always-defined-but-not-shown columns

Useful for fields you want filterable or queryable without cluttering the visible table:

{ key: "internal_notes", label: "Internal notes", hidden: true, searchable: true }

Quick check

You want a Contacts table that shows Name + email in column 1 (stacked), the group name in column 2, and the relative created-at in column 3 — just three columns. Best approach?

Try it

Customise your Contact table to look like a CRM:

  1. Drop the created_at column from the visible list.
  2. Replace the Name column with a stacked cell showing Name on top and email below (smaller, muted).
  3. Replace the Phone column with a stacked cell showing phone on top and group name below.
  4. Add a filter on group_id with options fetched from /api/groups.

Reload the page and confirm the layout is tighter and the filter works.

What's next

Forms and tables are the operator side. The final lesson in this chapter takes the same generated resource and shows you how to consume it from the customer-facing web app — using the auto-generated React Query hook from apps/web/hooks/use-<plural>.ts.

Spot a typo? Have an idea?

Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled — suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.

Suggest an improvement on GitHub