Admin UI — ResourceTable SSOT pattern
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 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 — Pryzeet"
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.