Step 4
AdminResourceTable component
35 min
AdminResourceTable component
The first few list pages can be written inline, but after ten pages the shared component clearly wins. Hand it title · icon · columns · rows · pagination and each page fits in 30–70 lines.
1. Props
export interface ResourceTableProps<Row> {
title: string;
subtitle?: string;
icon: ReactNode;
iconColor?: 'blue' | 'green' | 'amber' | 'rose' | 'violet';
actions?: ReactNode;
search?: { placeholder: string; value: string; onChange: (v: string) => void };
filters?: ReactNode;
columns: Array<{
header: string;
key: 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;
};
}
2. Implementation highlights
const colorMap = {
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 class strings must be complete at build time (text-${color}-600 gets purged), hence a typed lookup.
3. Usage — posts list
'use client';
export function PostsView({ initialRows }: { initialRows: Post[] }) {
const [q, setQ] = useState('');
const rows = initialRows.filter((p) =>
p.title.toLowerCase().includes(q.toLowerCase())
);
return (
<AdminResourceTable<Post>
title="Blog posts"
subtitle={`${rows.length} total`}
icon={<FileText className="h-7 w-7" />}
iconColor="blue"
search={{ placeholder: 'search title', value: q, onChange: setQ }}
columns={[
{ header: 'title', key: 'title' },
{ header: 'published', key: 'published', render: (p) => p.published ? '✓' : '—' },
{ header: 'created', key: 'created_at', render: (p) => p.created_at.slice(0, 10) },
]}
rows={rows}
rowKey={(p) => p.id}
emptyState={{ title: 'no posts', description: 'write your first post' }}
/>
);
}
4. URL-first search and pagination
const params = useSearchParams();
const router = useRouter();
const q = params.get('q') ?? '';
const onQChange = (v: string) => {
const u = new URLSearchParams(params);
if (v) u.set('q', v); else u.delete('q');
u.delete('page');
router.push(`?${u.toString()}`);
};
Refresh-safe, shareable, predictable.
5. Responsiveness and a11y
- Admins rarely use mobile;
overflow-x-autoon the table is usually enough <th scope="col">/<th scope="row">for screen readers- Empty state belongs inside
<tr><td colSpan>
Closing
It feels premature for the first few pages. Past the fifth it consistently pays off. Start minimal (title · columns · rows · pagination) and grow props only when real needs arise.
Next
- 05-oauth-allowlist