Data Tables: Sort, Filter, Pagination, Bulk Actions
Most B2B SaaS UIs are 60% data tables — invoices, contacts, orders, leads, tickets, transactions. Tables are the workhorse of every admin / dashboard / CRM / ops tool. The naive implementation: dump rows in HTML and call it done. The structured implementation: server-driven sort + filter + pagination + bulk-select with URL state and reasonable performance for 10K-1M+ rows. The patterns below come from looking at how Linear, Stripe, Notion, and Airtable build their tables — most of which are minor variations on the same primitives.
1. Decide: client-side, server-side, or virtualized
Three architectures, three break-points.
Generate the structural decision criteria for SaaS data tables in 2026:
Three viable architectures:
Architecture 1: Pure client-side (TanStack Table v9 / AG Grid)
- Fetch ALL rows from API once
- Sort/filter/paginate happens in browser
- Works for: <1000 rows
- Breaks for: large datasets (slow initial load)
Architecture 2: Server-driven (URL state → API params)
- Fetch only current page from API
- Sort/filter/paginate params go in URL → server query
- Works for: 1K-1M+ rows
- Standard for B2B SaaS
Architecture 3: Virtualized + infinite scroll (TanStack Virtual)
- Render only visible rows (e.g., 50 of 100K)
- Fetch in batches as user scrolls
- Works for: 10K-1M+ rows where pagination feels wrong
- Best for: feed-style UIs, audit logs, activity streams
Decision rules:
- <1000 rows always → client-side
- 1K-100K rows, traditional table feel → server-driven
- 100K+ rows OR feed-style UI → virtualized
For this product, I have [DESCRIBE TABLE: domain, expected row count, query complexity].
What architecture should I pick? What breaks if I scale 10x?
Output:
1. Recommended architecture
2. Library choices (TanStack Table v9, AG Grid, native HTML table)
3. Pagination strategy (offset, cursor, virtualized)
4. Failure modes at 10x scale
The hidden cost of pure-client-side: when usage scales past your row threshold, the page just gets slower with no clean upgrade path. Plan for server-driven from day one if you expect growth.
2. URL state — the table's source of truth
A B2B table that loses your filter when you click a row and back is broken. URL state fixes this.
Generate URL state schema for a Next.js 16 data table.
Requirements:
- All table state in URL query params (sort, filter, page, page_size, search)
- Bookmarkable / shareable URLs
- Browser back/forward works
- Server can pre-render the correct page from URL
- Initial paint shows the right rows (no flash of empty table)
Stack: Next.js 16 App Router, React Server Components, [STATE LIBRARY: nuqs / search-params].
Schema:
- ?sort=created_at:desc,name:asc — comma-separated sort keys
- ?filter[status]=active&filter[plan]=pro — bracket-syntax filters
- ?page=2&page_size=50 — pagination
- ?q=acme — search
Output:
1. URL parser/builder TypeScript types
2. Server-component code that reads URL → DB query
3. Client-component UI (sort headers, filter pills) that writes to URL
4. Loading skeleton during navigation
5. Edge cases: how to handle invalid URL params (default + sanitize, don't 500)
Why URL state matters: support engineers can paste a URL into Slack and the recipient sees the same view. Bookmarks work. Browser back works. Page is server-renderable.
3. Server-driven sort
Sort is the easiest piece. Allowlist columns, accept direction, pass to query.
Implement type-safe sort for a data table.
Stack: [STACK: Next.js / Express / FastAPI], [DB: Postgres / MySQL].
Requirements:
- Multi-column sort (sort by status DESC, then created_at DESC)
- Allowlist: only certain columns are sortable (prevent SQL injection / leak)
- Default sort if none specified
- Stable secondary sort on primary key (for deterministic pagination)
Sort URL format: ?sort=created_at:desc,name:asc
Allowed columns for table [TABLE_NAME]:
- created_at (default desc)
- name
- status
- amount
Output:
1. Parser: ?sort=... → typed SortClause[]
2. Validator: reject unknown columns / invalid directions
3. Query builder: SortClause[] → ORDER BY SQL (parameterized)
4. Always append PRIMARY KEY tiebreaker for stable pagination
5. Type tests: TypeScript should refuse non-sortable columns at compile time
The non-obvious requirement: always append a primary-key tiebreaker to sort. Without it, two rows with the same created_at flicker between pages 1 and 2 as the user paginates. Add ORDER BY created_at DESC, id DESC and it's deterministic.
4. Server-driven filter — the hard part
Filters get complex fast. Most products start simple and accidentally re-invent SQL in the URL.
Design filter system for a B2B SaaS table.
Three filter complexity tiers (pick the right one for the use case):
Tier 1: Faceted (the 70% case)
- Predefined filter chips: Status (Active/Pending/Churned), Plan (Free/Pro/Business)
- AND logic across facets, OR within a facet
- URL: ?filter[status]=active,pending&filter[plan]=pro
Tier 2: Saved views (the 20% case)
- User saves filter combos as named views ("My open tickets", "Overdue invoices")
- View = stored URL state
- Similar to Linear, Notion, Airtable
Tier 3: Query builder (the 10% case)
- Free-form: where status = "active" and amount > 100
- Operators: =, !=, <, >, contains, in, between
- Group with AND/OR/NOT
- Used in BI tools, advanced filters
For this table [DESCRIBE]: which tier?
Output:
1. Tier-appropriate URL schema
2. Server query builder (parameterized, no SQL injection)
3. Filter UI (chips for Tier 1, modal for Tier 3)
4. Empty-state messaging when filters return zero results
5. "Clear filters" button placement
Common mistake: jumping straight to Tier 3 query builder. Most users never use complex filtering — they use 3-5 chips. Build Tier 1 first, add Tier 2 when users ask for saved views, and only build Tier 3 when you have power users (BI / ops) demanding it.
5. Pagination — offset vs cursor
Two pagination models. Both have trade-offs.
Decide between offset and cursor pagination for [TABLE].
Offset pagination (?page=2&page_size=50):
- Pros: jump to arbitrary page; show total page count
- Cons: slow at deep offsets (OFFSET 10000 scans 10000 rows); rows shift if data changes mid-paginate
- Best for: small-to-medium tables (<100K rows)
Cursor pagination (?after=eyJpZCI6MTIzfQ&limit=50):
- Pros: O(1) regardless of depth; stable across inserts
- Cons: no "page 47"; can't jump
- Best for: large tables, feed-style UIs, infinite scroll
Hybrid:
- Offset for UI, cursor under the hood (compute cursor from page * size)
- Used by Stripe (page tokens that look like cursors)
Decide for table [TABLE_NAME] with [ROW_COUNT] rows.
Output:
1. Recommendation + reasoning
2. URL schema
3. Server query (offset OR cursor implementation)
4. Loading state during page change
5. Total count: cheap (cached) vs expensive (real-time COUNT) — pick a strategy
The COUNT trap: showing "Page 1 of 247" requires SELECT COUNT(*), which is expensive on large tables. Three options: (a) cache the count and show stale, (b) show "Page 1 of many" with no total, (c) skip totals entirely (cursor only).
6. Bulk select and bulk actions
Selecting 50 rows and applying an action is core to any ops tool.
Implement bulk select for a data table.
Requirements:
- Checkbox per row + master checkbox in header
- "Select all on page" vs "Select all matching filter" (Stripe-style)
- Selection persists across pagination (so you can select page 1 + page 2)
- Selection cleared on filter change (or warn user)
- Bulk action toolbar appears when ≥1 row selected
- Show selected count: "3 selected" / "All 1,247 selected"
Stack: React + [STATE: Zustand / URL state / context].
Output:
1. Selection state shape: Set<rowId> + isAllMatching boolean
2. Master-checkbox tri-state (none / some / all on page)
3. "Select all 1,247 matching" link when all-on-page selected
4. Bulk action toolbar: actions list + danger styling for destructive
5. Confirm modal for destructive actions (delete, archive, charge)
6. Progress UI for long-running bulk: "Processing 247 of 1,247..."
7. Partial-failure handling: 247 succeeded, 3 failed — show failures
The "select all matching" pattern: when user clicks master checkbox, it selects all 50 visible rows. Show a banner: "50 selected. Select all 1,247 matching this filter?" That second click sends a server-side bulk action that operates on the filter, not on individual IDs. Stripe / Notion / Airtable all do this.
7. Column visibility, density, and persistence
Power users want to customize their table view.
Design column controls for a data table.
Features:
- Show/hide columns (eye icon menu)
- Reorder columns (drag-and-drop in column header or settings menu)
- Column density (compact / comfortable / spacious row height)
- Column resize (drag column edge)
- Persistence: per-user, per-table
Persistence strategies:
- LocalStorage: free, doesn't sync across devices
- Server-stored user prefs: synced, requires API
- Combined: localStorage as cache, server as truth
Stack: React + [PERSISTENCE: localStorage / server prefs API].
Output:
1. Column config schema: { id, visible, order, width }
2. Settings UI (modal or popover)
3. Drag-and-drop reorder (TanStack DnD or dnd-kit)
4. Width-resize (mouse drag with debounced save)
5. Reset to default
6. Sync strategy if server-backed
Don't over-engineer this on day one. v1 = sort + filter + pagination. v2 = column visibility. v3 = reorder / resize. Most users never customize.
8. Performance — virtualization and indexes
When tables grow to 10K+ rows, two things slow down: (1) the database, (2) the DOM.
Audit performance for a data table at scale.
Database side:
- Every sortable column needs an index that matches sort + filter combo
- Composite indexes for common queries (e.g., (status, created_at DESC))
- EXPLAIN ANALYZE every common query — confirm Index Scan, not Seq Scan
- LIMIT + OFFSET at depth = slow; use cursor pagination for large tables
- Total COUNT is expensive; avoid or cache
Frontend side:
- Render only visible rows (virtualization) when row count > 100
- Use TanStack Virtual or react-window
- Memoize cell renderers (heavy cells: avatars, badges, formatted dates)
- Avoid layout thrash: fixed row height for virtualization
- Lazy-load heavy cells (images, formatted data) below fold
For this table [DESCRIBE]:
- Row count: [N]
- Common queries: [LIST]
- Render complexity: [DESCRIBE cells]
Output:
1. Required DB indexes (DDL)
2. Recommended virtualization library + config
3. Memoization plan (which cells, which deps)
4. Performance budget: TTI < 1s for first page, sort/filter < 200ms perceived
5. Monitoring: log slow queries, Web Vitals INP for sort/filter interactions
The frontend optimization most teams skip: fixed row height for virtualization. Variable row heights make virtual scroll janky and sometimes impossible to compute. If you need variable, use useVirtualizer with measured row heights, but expect more complexity.
9. Empty states and loading skeletons
Tables in B2B SaaS have three empty states. Most products only handle one.
Generate empty-state and loading UI for a data table.
Three empty states (handle each separately):
1. Initial empty: user has zero rows ever
- Message: "No customers yet. Add your first customer to get started."
- CTA: prominent "Add customer" button
- Optionally: illustration, sample data link
2. Filter empty: user has rows but current filter returns zero
- Message: "No customers match your filter."
- CTA: "Clear filters" button
- Don't show "Add customer" CTA — irrelevant
3. Search empty: user typed in search box, no matches
- Message: "No results for 'acme'."
- CTA: "Clear search" + suggestions
Loading states:
- Initial load: skeleton table with N placeholder rows
- Sort/filter change: subtle shimmer on existing rows (don't blank entire table)
- Pagination: keep current rows visible until next page loads (avoid flash)
Stack: React + Tailwind + shadcn/ui.
Output:
1. Skeleton row component
2. Three empty-state components
3. Page-transition strategy (preserve old rows during navigation)
4. Error state (API failure) with retry button
5. Accessibility: aria-busy, aria-live announcements for "no results"
The state that's most often wrong: filter-empty showing the same "Add customer" CTA as initial-empty. The user has customers — they just filtered them all out. Right CTA is "Clear filters."
10. Saved views and shareable URLs
The 20% upgrade that turns a table from useful to indispensable.
Implement saved views for a data table.
Requirements:
- User saves current URL state as named view: "My open tickets", "Overdue invoices"
- Views appear as tabs above the table or in a sidebar
- Default view + custom views per user
- Sharing: copy URL of a view → teammate sees same view (URL state, not stored)
- Personal views (private) vs team views (shared)
Stack: [STACK], [DB: Postgres].
Schema:
- views (id, name, table_key, url_state_json, owner_user_id, team_id (nullable), created_at)
- url_state_json: { sort, filter, page_size, columns, density }
UI:
- "Save view" button when current state differs from any saved view
- Tab bar above table with saved views
- Active view highlighted
- "Updated" indicator when current state differs from saved
- "Save changes" / "Save as new" actions
Output:
1. Database schema (migration)
2. API endpoints: list views, save view, update view, delete view
3. UI: tabs + save dialog
4. Conflict handling when 2 users edit same shared view
5. Default-view logic (per-user-per-table)
6. Privacy: respect team boundaries, don't leak views across orgs
This is the "feels like Linear / Notion" upgrade. Once you have it, users stop thinking of the table as a static list and start treating it as a workspace.
What Done Looks Like
A v1 table for B2B SaaS in 2026:
- Server-driven sort + filter + pagination, with URL state
- Allowlisted sortable columns with primary-key tiebreaker
- Faceted filters (Tier 1: status / plan / owner chips)
- Offset pagination with cached COUNT (or cursor for >100K rows)
- Bulk select with "select all matching" affordance
- Bulk action toolbar with destructive-action confirmations
- Three empty states (initial / filter / search) handled distinctly
- Loading skeleton + page-transition state preservation
- Indexes matching common (filter, sort) combinations
- Web Vitals: INP <200ms for sort/filter interactions
Add later when product is mature:
- Saved views (when 3+ users ask)
- Column visibility / reorder / resize (power users)
- Virtualization (when row count exceeds 1K and pagination feels wrong)
- Tier 3 query builder (BI / ops users only)
The mistake to avoid: building a Tier 3 query builder before you have Tier 1 chips. Power users describe what they want loudly; the median user just clicks chips. Build for the median, then layer power tools.
The second mistake: client-side everything because it's faster to ship. It is — until your table grows past 5K rows and you're rewriting on a deadline. Server-driven from the start adds two days to v1 and saves two weeks at scale.
See Also
- Search & Autocomplete Typeahead — search UX pairs with table search
- Bulk Operations — bulk action server implementation
- API Pagination Patterns — pagination at the API layer
- CSV Import — bulk data in
- Internal Admin Tools — admin tables similar patterns
- Settings & Account Management Pages — account-level table examples
- Database Indexing Strategy — index design for sortable columns
- Performance Optimization — frontend perf primer
- VibeReference: Headless Commerce Platforms — adjacent product UI surface
- VibeReference: Internal Tool Builders — Retool/Tooljet for admin tables
- LaunchWeek: Settings & Account Management Pages — table UX patterns