Skip to main content

Admin UI — ResourceTable SSOT pattern

An internal admin site, viewed numerically, is the sum of dozens of list pages. Users, posts, point transactions, reports. If each page is built differently, operators re-learn the UI per page and developers re-copy the…

5 viewsAbout 3 min read
Table of contents

An internal admin site, viewed numerically, is the sum of dozens of list pages. Users, posts, point transactions, reports. If each page is built differently, operators re-learn the UI per page and developers re-copy the same boilerplate. A shared component lowers both costs at once.

1. What every list page shares

  • Header — title + icon + subtitle + (add) action button
  • Search input — email · ID · slug filters
  • Filters — status · category · date range
  • Table — column headers + rows + custom cell renderers
  • Pagination — page · pageSize · total
  • Empty state — "no data" + recovery CTA
  • Loading / error

Written from scratch in React, each page takes 30–100 lines of boilerplate. Extract it and the page body shrinks to 30 lines: a query and a row mapping.

2. Props design

interface ResourceTableProps<Row> {
  title: string;
  subtitle?: string;
  icon: ReactNode;
  iconColor: 'blue' | 'green' | 'amber' | 'rose' | 'violet';
  actions?: ReactNode;
  headersOnly?: boolean;                 // hero only, no table
  search?: { placeholder: string; value: string; onChange: (v: string) => void };
  filters?: ReactNode;
  columns: Array<{
    header: string;
    key: keyof Row | string;
    render?: (row: Row) => ReactNode;
    className?: string;
  }>;
  rows: Row[];
  rowKey: (row: Row) => string | number;
  emptyState?: { title: string; description?: string; action?: ReactNode };
  pagination?: {
    page: number; pageSize: number; total: number;
    onPageChange: (p: number) => void;
  };
}

Binding header + table into one component automatically unifies page layout. An explicit headersOnly opt-out keeps form pages consistent with the same hero look.

3. Usage

<ResourceTable
  title="Audit Log — Catalog"
  subtitle="Per-domain admin action tracking"
  icon={<ClipboardList />}
  iconColor="violet"
  search={{ placeholder: 'email · resource id', value: q, onChange: setQ }}
  filters={
    <Select value={action} onValueChange={setAction}>
      <SelectItem value="">all actions</SelectItem>
      <SelectItem value="DELETE">DELETE</SelectItem>
      <SelectItem value="UPDATE">UPDATE</SelectItem>
    </Select>
  }
  columns={[
    { header: 'time', key: 'created_at', render: (r) => formatDate(r.created_at) },
    { header: 'user', key: 'user_email' },
    { header: 'action', key: 'action' },
    { header: 'resource', key: 'resource' },
    { header: 'reason', key: 'details', render: (r) => r.details?.reason ?? '—' },
  ]}
  rows={logs}
  rowKey={(r) => r.id}
  pagination={{ page, pageSize: 20, total, onPageChange: setPage }}
  emptyState={{ title: 'no logs', description: 'try different filters' }}
/>

The page body is just useState, a DB query, and this component. Per-page 30–70 lines.

4. Color map SSOT

const iconColorMap = {
  blue:   { bg: 'bg-blue-50',   fg: 'text-blue-600'   },
  green:  { bg: 'bg-green-50',  fg: 'text-green-600'  },
  amber:  { bg: 'bg-amber-50',  fg: 'text-amber-600'  },
  rose:   { bg: 'bg-rose-50',   fg: 'text-rose-600'   },
  violet: { bg: 'bg-violet-50', fg: 'text-violet-600' },
} as const;

Tailwind requires complete class strings at build time (text-${color}-600 is purged). A typed lookup table keeps it deterministic. Six to eight colors stays practical; beyond that, move to CSS variables.

5. Accessibility

  • Use <table> semantics. Avoid role="table"
  • Column headers <th scope="col">, row headers <th scope="row">
  • Sortable headers: aria-sort="ascending" | "descending" | "none"
  • Empty states belong inside <caption> or <tbody> — screen readers then know "empty table"
  • Pagination inside <nav aria-label="pagination">

6. Server / client boundary

ResourceTable itself is 'use client', but fetch data on the Server and pass as props. This plays well with Next.js App Router.

// page.tsx (Server)
export default async function Page({ searchParams }) {
  const sp = await searchParams;
  const { rows, total } = await queryLogs(sp);
  return <LogsView initialRows={rows} initialTotal={total} />;
}

Search · filter state rides URL params (shareable · refresh-safe). The table stays a lightweight client component.

7. Gotchas

Pagination as client-only state — losing it on refresh is painful for operators who share URLs. URL-first.

Too many props — past 10 props the component feels heavy. Keep core (columns) and offer slot props (headerSlot, toolbarSlot).

Debounce scattered per page — rely on a useDebounced hook on the call side so search.onChange receives already-debounced values.

Barren empty state — "no data" alone is ambiguous ("filter fail" vs "truly empty"). Include "clear filters" or "add new" CTA.

Closing

For the first 3–5 list pages, a shared table component can feel over-engineered. Past ten pages, consistency · velocity · operator ramp-up all benefit significantly. Start minimal (title · columns · rows · pagination) and grow props only on real need.

Next

  • nextjs-app-router
  • material3-tokens

References: WAI-ARIA Authoring Practices — Table · shadcn/ui Data Table · TanStack Table.

More in frontend

All in this category →

Related posts

Was this article helpful?