[data-reveal]{opacity:1!important;transform:none!important}
Extending The Dashboard

TableView displays

Register an alternate TableView body renderer — grid, kanban, cards, calendar — that inherits the table's search, filters, sort, tabs, pagination, selection, and row actions.

A TableView's toolbar (Tables) — search, filters, sort, tabs, pagination, selection, row actions — drives one shared list query; the body is just one way to paint the result. A display swaps that body. The grid is the default, kanban ships as a built-in alternative, and your own display (cards, calendar, gallery, map…) inherits the entire toolbar for free.

A display is two declarations joined by an id:

  • a client registration — label, icon, body component, availability — from a .client plugin in your frontend layer;
  • a backend opt-in — the table view's config offers the display, picks the default, and declares its data behaviour and chrome.

Register the display type

Register once, globally, from a .client plugin. Registering an id that already exists replaces it, so a project can also override a built-in display's presentation.

app/plugins/my-display.client.ts
import MyDisplay from "../components/MyDisplay.vue";

export default defineNuxtPlugin(() => {
  registerTableViewDisplay({
    id: "cards",
    label: "Cards",
    icon: "i-ph-cards",
    order: 30,
    component: MyDisplay,
  });
});

A TableViewDisplay:

FieldTypeNotes
idstringStable id; matches displays[].id, defaultDisplay, and the persisted user preference.
labelstringi18n key or literal label shown in the view switcher.
iconstringIcon for the switcher.
component?Component | ComponentInfoBody renderer — a component, or a ComponentInfo resolved by name. undefined means "render the built-in grid".
order?numberSwitcher ordering, lower first. Defaults to 100.
isAvailable?(ctx) => booleanHide the display for a table it cannot render; ctx is { columns, options? }. Kanban uses it to require a group-by column.

The registration lives in a .client plugin because it carries callables — a Vue component, an availability predicate — that cannot be serialized. It exists only in the browser and powers the view switcher after hydration.

The display context

The body component receives one prop, context (a TableViewDisplayContext). Paint items and the display inherits search, filters, sort, and pagination automatically — they all feed the same shared query.

app/components/MyDisplay.vue
const props = defineProps<{ context: TableViewDisplayContext }>();
FieldTypePurpose
itemsT[]Current page rows from the shared list query. Empty for self-managed displays.
columnsTableViewColumn[]Full column metadata (types, labels, accessor keys).
loadingbooleanShared query is in flight.
selectionTableViewDisplaySelection{ ids, isSelected(id), toggle(id, value?), toggleAll(value?), clear() } — one selection state shared with the grid.
paginationTableViewDisplayPagination{ pageIndex, pageSize, total, setPage(i), setPageSize(n) } — mutations re-run the shared query.
actionsTableViewDisplayActions<T>canAdd/canEdit/canDelete/canDetails flags, per-row canEditRow(item) / canDeleteRow(item), plus add, edit, delete(ids), duplicate(id), details, custom.
presenceByRowTableViewPresenceMapRealtime presence keyed by row id — for "X is editing this" affordances.
rowIdKeystringField used as the row id (default _id).
labelKey?stringField used as an item's human title, if configured.
locationstringData API location — for self-managed fetching.
queryRecord<string, unknown>Active filters/search/sort (no limit/offset) — for self-managed fetching.
componentId? / pageId?stringStable identifiers, e.g. for per-instance fetch keys.
options?Record<string, unknown>Per-instance options declared for this display in the backend config.
refresh()() => Promise<void> | voidRe-fetch the table view.
table?Table<T>Escape hatch: the TanStack table instance, when rendered inside the body slot. Advanced displays only.

The per-row predicates matter: actions.canEditRow(item) and actions.canDeleteRow(item) evaluate conditional rules, so a display can gate an affordance on the individual item rather than a blanket flag.

Most displays consume items and inherit everything. A self-managed display (selfManagedData: true in the backend config) skips the shared list query — items arrives empty — and fetches its own data from context.location using context.query. Expose a refresh() method so the table view can re-sync it on realtime updates and manual refresh. The built-in kanban works this way.

Offer it from the table view

A registered display type is inert until a table view offers it. The implicit table display is always offered; everything else goes in displays[], with defaultDisplay choosing what shows when the user has no saved preference (defaults to "table").

src/pages/tasks.ts
static table = TableView(taskDataAPI, {
  caption: "Tasks",
  labelKey: "name",
  rowActions: { add: true, edit: true, delete: true, hasSelection: true },
  displays: [{ id: "cards", component: CustomComponent("MyDisplay") }],
  defaultDisplay: "cards",
});

Each entry is a TableViewDisplayConfig:

FieldTypeNotes
idstringMatches a registered display id.
options?Record<string, unknown>Per-instance options forwarded to the body via context.options.
component?ComponentInfoSSR-safe component reference — provide it so the body renders during SSR (see below).
selfManagedData?booleanWhen true, the shared list query is skipped for this display.
capabilities?TableViewDisplayCapabilitiesChrome shown while this display is active (see below). A capability turned off also stops its persisted state from narrowing the shared query.

Capabilities and their defaults:

CapabilityDefaultControls
columnManagementfalseColumn visibility / pin / size / order (grid-only)
filterstrueFilters row and filter trigger
searchtrueGlobal search box
sortingtrueSort-by-field menu
tabstrueFilter tabs

Behaviour lives on the backend config — not the client registry — because the server renders first: selfManagedData decides whether to run the shared list query, and capabilities decides which toolbar controls render. Deciding those client-side would differ between server and client and produce hydration mismatches. For the same reason, reference the body component from displays[].component too, so it renders during SSR; the client registry confirms the same component after hydration.

The available displays are the intersection of three sets: the client registry, the ids the backend offers, and each display's isAvailable rule. The active one is restored from the user's persisted preference, with two guards: a stale persisted id is clamped back to the offered set, and a display that turns out unavailable after hydration degrades to the grid.

Built-in displays

Two displays ship out of the box:

  • table (order: 10) — the grid. It has no component, which is the signal to render the table view's own body, and it is the only display with columnManagement on.
  • kanban (order: 20) — its isAvailable requires an eligible group-by column (single-select, boolean, or status), and its defaults set selfManagedData: true and tabs: false.

These built-in defaults merge in per id: offering a built-in display doesn't restate them, and a partial override merges key-by-key — overriding one kanban capability keeps the rest.

Example: a cards display

A complete custom "cards" display is three files: the client registration, the body component, and the backend opt-in.

1. Register the type from a .client plugin in your frontend layer:

nuxt-layer/app/plugins/table-view-cards-display.client.ts
import TaskCardsDisplay from "../components/TaskCardsDisplay.vue";

export default defineNuxtPlugin(() => {
  registerTableViewDisplay({
    id: "cards",
    label: "Cards",
    icon: "i-ph-cards",
    order: 30,
    component: TaskCardsDisplay,
  });
});

2. The body component paints context.items and wires selection, actions, and pagination back through the context:

nuxt-layer/app/components/TaskCardsDisplay.vue
<script setup lang="ts">
const props = defineProps<{ context: TableViewDisplayContext }>();

const rowId = (item: Record<string, unknown>): string => String(item[props.context.rowIdKey] ?? "");

const title = (item: Record<string, unknown>): string => {
  const value = props.context.labelKey ? item[props.context.labelKey] : undefined;
  return value != null && value !== "" ? String(value) : rowId(item);
};

const currentPage = computed({
  get: () => props.context.pagination.pageIndex + 1,
  set: (page: number) => props.context.pagination.setPage(page - 1),
});
</script>

<template>
  <div v-if="context.loading" class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
    <USkeleton v-for="n in 6" :key="n" class="h-32 w-full" />
  </div>

  <div v-else-if="context.items.length === 0" class="py-10 text-center text-sm">No items</div>

  <div v-else class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
    <div
      v-for="item in context.items"
      :key="rowId(item)"
      class="rounded-lg border p-4"
      :class="{ 'cursor-pointer': context.actions.canEdit }"
      @click="context.actions.canEdit && context.actions.edit(item)"
    >
      <div class="flex items-center gap-2">
        <UCheckbox
          :model-value="context.selection.isSelected(rowId(item))"
          @click.stop
          @update:model-value="(v) => context.selection.toggle(rowId(item), !!v)"
        />
        <p class="truncate text-sm font-semibold">{{ title(item) }}</p>
        <UButton
          v-if="context.actions.canDelete"
          icon="i-ph-trash"
          color="error"
          variant="ghost"
          size="xs"
          @click.stop="context.actions.delete([rowId(item)])"
        />
      </div>
    </div>
  </div>

  <UPagination
    v-model:page="currentPage"
    :total="context.pagination.total"
    :items-per-page="context.pagination.pageSize"
  />
</template>

3. Opt the table in from the backend page controller, with an SSR-safe component reference:

src/pages/tasks-cards.ts
static table = TableView(taskDataAPI, {
  caption: "Tasks — Cards",
  labelKey: "name",
  rowActions: {
    add: true,
    edit: { isVisible: true },
    delete: { isVisible: true },
    details: true,
    duplicate: true,
    hasSelection: true,
  },
  displays: [{ id: "cards", component: CustomComponent("TaskCardsDisplay") }],
  defaultDisplay: "cards",
});

When the user types in the shared search box or flips a filter, the shared query re-runs and items updates underneath the cards — no extra wiring. To react to those changes elsewhere on the page, use the table view's component events.