Skills
table-pagination
table-pagination
npx @loomcraft/cli add skill table-paginationFrontmatter
Name
table-pagination
Description
Data table patterns with server-side pagination, toolbar, and responsive columns. Use when building data tables, implementing search and pagination, or creating admin list views.
Content
# Table & Pagination Patterns
## Critical Rules
- **Server-side pagination** — paginate in the database, not in the client.
- **URL-based state** — page, limit, search, sort in query params for shareable URLs.
- **Toolbar = search + counter + limit** — consistent UX across all tables.
- **Responsive columns** — hide columns with `hidden sm:table-cell` breakpoints.
- **Reset page on filter change** — always return to page 1 when search/filter changes.
- **Use `tabular-nums`** on numeric columns — for proper alignment.
## Table Architecture
```
DataTable (container)
├── Toolbar (search + filters + counter + limit selector)
├── Table (columns + rows)
└── Pagination (page navigation)
```
## Server-Side Pagination
### URL-Based State
```tsx
// app/users/page.tsx
import { Suspense } from "react";
import { getUsersPaginated } from "@/facades/user.facade";
interface Props {
searchParams: Promise<{
page?: string;
limit?: string;
search?: string;
sort?: string;
order?: string;
}>;
}
export default async function UsersPage({ searchParams }: Props) {
const params = await searchParams;
const page = Number(params.page) || 1;
const limit = Number(params.limit) || 20;
const { data, total } = await getUsersPaginated({
page,
limit,
search: params.search,
sort: params.sort,
order: params.order as "asc" | "desc",
});
return (
<DataTable
columns={columns}
data={data}
total={total}
page={page}
limit={limit}
/>
);
}
```
### Backend Pagination
```ts
// src/dal/user.dal.ts
import { db } from "@/lib/db";
import { users } from "@/schema";
import { sql, ilike, desc, asc } from "drizzle-orm";
interface PaginationParams {
page: number;
limit: number;
search?: string;
sort?: string;
order?: "asc" | "desc";
}
export async function findUsersPaginated(params: PaginationParams) {
const { page, limit, search, sort = "createdAt", order = "desc" } = params;
const offset = (page - 1) * limit;
const where = search ? ilike(users.name, `%${search}%`) : undefined;
const orderBy = order === "asc" ? asc(users[sort]) : desc(users[sort]);
const [data, [{ count }]] = await Promise.all([
db.select().from(users).where(where).orderBy(orderBy).limit(limit).offset(offset),
db.select({ count: sql<number>`count(*)` }).from(users).where(where),
]);
return { data, total: Number(count) };
}
```
## Table Toolbar
```tsx
"use client";
export function DataTableToolbar({ table, total }: ToolbarProps) {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
function updateParam(key: string, value: string) {
const params = new URLSearchParams(searchParams);
if (value) params.set(key, value);
else params.delete(key);
params.delete("page"); // reset to page 1 on filter change
router.push(`${pathname}?${params}`);
}
return (
<div className="flex items-center justify-between gap-2">
<div className="flex flex-1 items-center gap-2">
<Input
placeholder="Search..."
defaultValue={searchParams.get("search") ?? ""}
onChange={(e) => updateParam("search", e.target.value)}
className="h-8 w-[250px]"
/>
<span className="text-sm text-muted-foreground">
{total} result{total !== 1 ? "s" : ""}
</span>
</div>
<div className="flex items-center gap-2">
<Select
defaultValue={searchParams.get("limit") ?? "20"}
onValueChange={(v) => updateParam("limit", v)}
>
<SelectTrigger className="h-8 w-[80px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[10, 20, 50, 100].map((n) => (
<SelectItem key={n} value={String(n)}>{n}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
}
```
## Responsive Columns
Hide columns by breakpoint to keep tables readable on mobile:
```tsx
const columns: ColumnDef<User>[] = [
{
accessorKey: "name",
header: "Name",
// Always visible
},
{
accessorKey: "email",
header: "Email",
meta: { className: "hidden sm:table-cell" },
},
{
accessorKey: "role",
header: "Role",
meta: { className: "hidden md:table-cell" },
},
{
accessorKey: "createdAt",
header: "Created",
meta: { className: "hidden lg:table-cell" },
cell: ({ row }) => formatDate(row.getValue("createdAt")),
},
];
```
Apply the responsive class in the table cell:
```tsx
<TableCell className={column.columnDef.meta?.className}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
```
## Pagination Component
```tsx
"use client";
export function DataTablePagination({ page, limit, total }: PaginationProps) {
const totalPages = Math.ceil(total / limit);
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
function goToPage(p: number) {
const params = new URLSearchParams(searchParams);
params.set("page", String(p));
router.push(`${pathname}?${params}`);
}
return (
<div className="flex items-center justify-between px-2">
<p className="text-sm text-muted-foreground">
Page {page} of {totalPages}
</p>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => goToPage(page - 1)} disabled={page <= 1}>
Previous
</Button>
<Button variant="outline" size="sm" onClick={() => goToPage(page + 1)} disabled={page >= totalPages}>
Next
</Button>
</div>
</div>
);
}
```
## Do
- Paginate in the database with `LIMIT`/`OFFSET` — never fetch all rows and slice on the client.
- Store page, limit, search, and sort in URL query params for shareable, bookmarkable state.
- Reset to page 1 whenever search or filter criteria change.
- Use `tabular-nums` on numeric columns and dates for proper alignment.
- Hide non-essential columns on smaller breakpoints with responsive utility classes.
## Don't
- Don't fetch all data and paginate client-side — it does not scale beyond a few hundred rows.
- Don't store pagination state in React state — use URL search params for persistence and sharing.
- Don't forget the total count query — you need it for "Page X of Y" display.
- Don't fire a search request on every keystroke without debouncing.
## Anti-Patterns
| Anti-Pattern | Problem | Fix |
|---|---|---|
| **Client-side pagination** | Loads all rows into memory, slow on large datasets | Use server-side `LIMIT`/`OFFSET` with a total count query |
| **Pagination state in `useState`** | State is lost on refresh, URLs are not shareable | Store `page`, `limit`, `search`, `sort` in URL search params |
| **Not resetting page on filter change** | User sees empty results or wrong page after filtering | Delete the `page` param whenever search or filter params change |
| **Search without debounce** | Fires a request per keystroke, overwhelming the server | Debounce search input (300ms) before updating the URL param |
| **Missing total count** | Cannot display "Page X of Y" or disable next button correctly | Run a parallel `COUNT(*)` query alongside the paginated data query |Files
No additional files