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…
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. Avoidrole="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.