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

// 14 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: "spec_sheet", format: "file" } // FileRef preview (v3.31.30)
{ key: "photos", format: "files" } // FileRef[] gallery stack
{ 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" } // packed avatar + name + email
The ColumnFormat TS union currently lists 13 of these — file and files render fine at runtime but you may need as never to satisfy the compiler if you set them by hand. They're emitted automatically by the generator for :file: and :files: columns.

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" },
},
}

Auto-packed columns (v3.31.19+)

Starting in v3.31.19, the generator detects two common pack patterns and emits a stacked column automatically. Generate a resource with both name and email and you'll get a single Contact column showing name on top and email below — no manual cell: callback needed.

Terminal
$grit generate resource Customer \
$ --fields "name:string,email:string:unique,phone:string,company:string"

The generated resources/customers.ts contains:

apps/admin/resources/customers.ts (excerpt)
import { defineResource } from "@/lib/resource";
import { StackedCell } from "@/components/tables/stacked-cell";
columns: [
// grit:cols:auto-start
// Packed automatically: name + email → single Contact column.
{ key: "name", label: "Contact", sortable: true, searchable: true,
cell: (row) => StackedCell({ top: String(row.name ?? ""), bottom: String(row.email ?? "") }) },
{ key: "phone", label: "Phone", sortable: true, searchable: true },
{ key: "company", label: "Company", sortable: true, searchable: true },
// grit:cols:auto-end
],

Patterns the generator recognises

Fields detectedPacked column
name + email"Contact" — name top, email muted below
first_name + last_name"Name" — concatenated full name
The StackedCell helper is just a function — calling StackedCell({ top, bottom }) returns the rendered JSX. That's why this works from a .ts file without forcing .tsx. Use the helper for any custom pack you write by hand too.

Resources scaffolded before v3.31.19 don't auto-pack — either add the pack manually following Recipe 4 below, or wait for the grit pack table <Resource> CLI command (roadmap).

Recipe 4 — pack multiple fields into one column (manual)

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.

Date-window filter (v3.31.34+)

Every list page ships with a toolbar Date filter — Today, Last 7 days, Last 30 days, This month, or a custom range. It rewrites ?created_since= / ?created_from= / ?created_to= into the list query and feeds the same params to every stat card on the page, so the numbers above the table always match the rows below.

table: {
// Defaults to enabled on created_at.
dateFilter: { enabled: true, field: "created_at", label: "Created" },
// Hide entirely:
// dateFilter: { enabled: false },
// Filter on a different timestamp instead — Booking by scheduled_for,
// BlogPost by published_at, Order by placed_at:
// dateFilter: { field: "scheduled_for", label: "Scheduled" },
},

The selection is URL-persisted via ?date=preset / ?date_from / ?date_to, so a refresh or shared link rehydrates the same view. No code changes needed on the API — the paginate package already parses all four params.

Export + Import (v3.31.35+)

The toolbar also ships a split Export button (CSV / Excel / JSON) and an Import button. Both run client-side via SheetJS — no new API routes, no async cutoff.

table: {
// All three formats on by default; default click triggers Excel.
// Hide a format from the menu:
export: { csv: true, excel: true, json: false },
// Disable export entirely:
// export: false,
// Excel import on by default. Restrict to a subset of form fields:
import: { fields: ["title", "price", "stock"] },
// Disable import entirely:
// import: false,
},

Export loops the paginated endpoint until every row is fetched (using the current filter + sort + date range), then writes the file. Import opens a modal that parses a dropped file, previews per-row validation errors, and POSTs each valid row at concurrency 4. File-field columns (:file: / :files:) are auto-excluded from imports — spreadsheets can't carry binary blobs.

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