{
  "id": "add-an-admin-table",
  "type": "playbooks",
  "category": "playbooks",
  "locale": "en",
  "url": "/playbooks/add-an-admin-table",
  "title": "Prompt-to-PR: Add an Admin Data Table",
  "description": "SOP for adding a server-side paginated, sortable, searchable admin data table in Next.js using TanStack Table and a PostgreSQL query — no ORM magic.",
  "tools": [
    "Cursor",
    "Claude Code",
    "Codex",
    "Windsurf"
  ],
  "stack": [
    "Next.js",
    "TypeScript",
    "PostgreSQL",
    "Tailwind"
  ],
  "tags": [
    "nextjs",
    "postgres",
    "typescript",
    "tailwind",
    "auth"
  ],
  "difficulty": "medium",
  "updated": "2026-06-08",
  "markdown": "Admin tables are a common AI coding task that frequently produces insecure, fully client-side implementations. This playbook keeps pagination and sorting server-side and explicitly guards the route.\n\n## 1. Requirement\n\nAdd an `/admin/users` page accessible only to users with `role = \"admin\"`. The table is server-side paginated (25 rows), sortable by column, and searchable by email. Data comes from a PostgreSQL `users` table via a parameterized query — no unparameterized SQL.\n\n## 2. First Prompt\n\n```txt title=\"First Prompt\"\nAdd an admin users table to this Next.js 15 App Router project.\n\nRequirements:\n1. Create `src/app/admin/users/page.tsx` — an async Server Component.\n   - Protect it: check the session (using the existing auth helper at\n     src/lib/auth.ts). If no session or session.user.role !== \"admin\",\n     call notFound() or redirect to /.\n   - Read `page` (default 1), `sort` (default \"createdAt\"), `order`\n     (\"asc\"|\"desc\", default \"desc\"), and `q` (search string) from\n     `searchParams`.\n   - Fetch data by calling a function `getUsers({ page, sort, order, q })`\n     from `src/lib/queries/users.ts`.\n   - Pass results to `<UsersTable>` client component.\n2. Create `src/lib/queries/users.ts` exporting `getUsers`.\n   - Use parameterized SQL via the existing db client (Drizzle or postgres.js,\n     whichever is in this project).\n   - Allowed sort columns whitelist: [\"email\",\"name\",\"createdAt\",\"role\"].\n     Reject any other sort value by defaulting to \"createdAt\".\n   - Return `{ rows: User[], total: number }`.\n   - Page size: 25.\n3. Create `src/components/admin/UsersTable.tsx` — a Client Component using\n   TanStack Table v8.\n   - Columns: email, name, role, createdAt (formatted), actions (Edit link).\n   - Column headers are clickable to sort — update URL search params via\n     useRouter/useSearchParams (do not manage sort state locally).\n   - Render a search input that debounces 300 ms before updating the URL.\n   - Render a pagination row: Previous / Next and \"Page N of M\".\n4. Install `@tanstack/react-table` if not already present.\n5. Do not implement the Edit action — leave it as a placeholder link.\n```\n\n## 3. Expected File Changes\n\n```txt\npackage.json                                   (@tanstack/react-table if missing)\nsrc/app/admin/users/page.tsx                   (new — protected Server Component)\nsrc/lib/queries/users.ts                       (new — parameterized query)\nsrc/components/admin/UsersTable.tsx            (new — TanStack Table client component)\n```\n\n## 4. Review Checklist\n\n- The page checks `session.user.role === \"admin\"` server-side — no client-only guard.\n- `getUsers` whitelists allowed sort columns before interpolating into SQL — no raw user input in column names.\n- All WHERE/LIMIT/OFFSET values use parameterized placeholders, not string concatenation.\n- Search (`q`) uses `ILIKE '%' || $1 || '%'` with a parameter, not string interpolation.\n- TanStack Table is used in `\"use client\"` component only — no `@tanstack/react-table` import in Server Components.\n- URL search params drive sort/page/search state — browser Back button works correctly.\n- `total` count comes from a `SELECT COUNT(*)` in the same query function — not a full table scan separate from the paged query.\n\n## 5. Test Commands\n\n```bash\nbun dev\n\n# Hit the page as an unauthenticated user\ncurl -I http://localhost:3000/admin/users\n# Expect: 404 or redirect, not 200\n\n# Log in as an admin user in the browser, visit /admin/users\n# Confirm: 25 rows, sortable columns, search, pagination\n\n# SQL audit — confirm no dynamic column names slip through\ngrep -n \"sort\\|order\\|column\" src/lib/queries/users.ts\n\nbun tsc --noEmit\nbun test\n```\n\n## 6. Common Failures\n\n- **SQL injection via sort column** — agent interpolates `sort` directly: `` `ORDER BY ${sort}` ``. Must use a whitelist lookup to a safe column name string.\n- **`searchParams` is async in Next.js 15+** — must be `await`ed: `const { page } = await searchParams`.\n- **TanStack Table renders in a Server Component** — build error because it uses `useState`. Move it to a `\"use client\"` component.\n- **`total` count off by one page** — `Math.ceil(total / 25)` gives wrong page count if `total` is 0. Guard: `Math.max(1, Math.ceil(total / 25))`.\n- **Role check missing** — agent only checks for a session, not the admin role. Any logged-in user can access the table.\n\n## 7. Fix Prompt\n\n```txt title=\"Fix Prompt\"\nThe sort column in getUsers is interpolated directly into SQL:\n  `ORDER BY ${sort} ${order}`\nThis is a SQL injection risk.\n\nFix: create an allowlist object:\n  const ALLOWED_SORT: Record<string, string> = {\n    email: \"users.email\",\n    name: \"users.name\",\n    createdAt: \"users.created_at\",\n    role: \"users.role\",\n  };\n  const safeSort = ALLOWED_SORT[sort] ?? \"users.created_at\";\n\nThen use `safeSort` in the query. The order direction must also be\nvalidated: only accept \"asc\" or \"desc\", default to \"desc\".\n```\n\n## 8. PR Description\n\n```md title=\"PR description\"\n## Feature: Admin users table at /admin/users\n\n- Server-side pagination (25 rows/page), sorting, and email search\n- Role guard: redirects non-admins server-side (no client-only check)\n- Parameterized SQL with sort-column whitelist — no injection surface\n- TanStack Table v8 client component; sort/page/search state lives in URL\n- `getUsers` returns `{ rows, total }` from a single query function\n```"
}