codingstairs
NotesEDULifeContact
⌕Search⌘K
koen

Navigation

  • Intro
  • Blog
  • Life

Get in touch

Send without signing in. Add your email if you'd like a reply.

  • Leave a message anonymously →
  • ✉ warragon112@gmail.com
  • KakaoTalk Open Chat ↗

© 2026 codingstairs

  • Notes
  • EDU
  • Search
  • Life
  • Contact
  • Legal
  • RSS
  • GitHub
Notes›frontend

Admin UI — ResourceTable SSOT pattern

Published 2026-05-06· Updated 2026-05-18·0 views

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. 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 →
  • Dashboard widget uniformity — don''t leave 4 domains with 3 widgets
  • Page Loading UX
  • Native Integrations — OS Features
  • OCR · STT · TTS
  • SQLite — A Single-File DB for Local Apps
  • Tauri Mobile and AdMob