Digging Deeper
Data Tables
On this page
Introduction
A list that people sort, filter, page through, tick rows in, act on and download is where the classic leaks of a web app live: a
hidden column that still travels in the props, a sort parameter that reaches ORDER BY, a bulk action that takes another tenant's
ids, a cell that a spreadsheet runs as a formula. A data table handles them once. You write one definition per table on the
server; the URL holds its state and is parsed strictly; its actions and exports are endpoints that check everything again. The
browser half is headless: useTable() gives a component what the props allow and renders nothing itself.
Install the package first:
pnpm add @marmeon/tableThis page builds the members table of the web app: every member with a confirmed address and the number of their links, an e-mail column only administrators see, a row action, a bulk action and a CSV export.
import type { Authenticated } from '@marmeon/auth';
import { action, bulk, column, defineTable, exportCsv, filter, type TableContext } from '@marmeon/table';
import { RemoveMemberLinks } from '../actions/RemoveMemberLinks.ts';
import { RemoveMembersLinks } from '../actions/RemoveMembersLinks.ts';
import { MemberPolicy } from '../policies/MemberPolicy.ts';
export const MembersTable = defineTable({
name: 'members',
query: (db, _ctx: TableContext<Authenticated>) =>
db.selectFrom('users').withCount('profile_links', { as: 'links' }).where('users.email_verified_at', 'is not', null),
key: 'users.id',
columns: {
name: column('users.name').sortable().searchable(),
email: column('users.email').visible(MemberPolicy, 'viewEmails').sortable().searchable(),
joined: column('users.created_at').sortable(),
links: column('links').sortable(),
},
filters: {
joined: filter.dateRange('users.created_at'),
locale: filter.select('users.locale', ['en', 'de']),
},
defaultSort: 'name',
pagination: { perPage: [10, 25, 50] },
actions: { removeLinks: action(RemoveMemberLinks).visible(MemberPolicy, 'moderate').authorize(MemberPolicy, 'removeLinks').confirm() },
bulk: { removeLinks: bulk(RemoveMembersLinks).visible(MemberPolicy, 'moderate').authorize(MemberPolicy, 'removeLinks').confirm().max(100) },
export: { csv: exportCsv().authorize(MemberPolicy, 'export') },
});defineTable() is typed from the base query's tables, so a column that does not exist, a sort that is not sortable or an ability
the policy lacks is a compile error.
Defining a table
The base query
query(db, ctx) returns every row the table may ever show in this request. Pages, actions, bulk actions and exports all start
from it, so it is the boundary: put the tenant here, .where('users.team_id', '=', ctx.user.team_id), and nothing of another
tenant can be shown, changed or downloaded. The base query selects nothing but values it computes, such as a
count. The table selects the visible columns and the key itself. Type its ctx as
TableContext<…> with what the routes' middleware adds, such as Authenticated.
Columns
A column is a column of the query: column('name'), or column('users.name') for a join. Its key in columns is its alias,
the only name the URL, the props and the export ever use. The members table calls users.created_at joined, and no URL ever
says created_at.
| Method | What it does |
|---|---|
.sortable() | The table may sort by it. |
.searchable() | The search looks into it while the user can see it: case-insensitive, with LIKE's wildcards taken literally. |
.label(text) | Its heading in the props and in the CSV. Without it, the props carry label: null, the page labels the column through useTable()'s labels, and the CSV heading is the alias. |
.visible(Policy, 'ability') | Only users the policy allows see it. Asked once per request, without a row. |
.visible((ctx, resolve) => …) | The same with a function of your own. |
A hidden column is not selected, not sent and not exported, and in the row's type it is optional. A column that a migration marked
.secret(), such as a password hash, cannot be a column at all: that is a compile error, and a column that names a secret(eb, …)
of the base query is refused when the table runs.
A column can also show a value the base query computes under a name: withCount('profile_links', { as: 'links' }) gives
links: column('links'), typed as a number, sorted by in ORDER BY without a join. Its key is that name. It cannot be
searched or filtered by, since WHERE cannot see a value the query computes; both are compile errors. Hidden by .visible(…), the
value is left out of the base query too, so it never reaches the SQL.
The key
The key identifies a row, and it is what actions send. A number column goes by name, key: 'users.id', and is read as an integer.
A text key says how to read it: key: { column: 'users.uuid', type: 'uuid' }, with the types integer, bigint, string and
uuid. A text key by name alone is a compile error. Every key from the browser is parsed by its type before any query. One that
does not parse is no row, so text never reaches an integer column. In the props, the key of a row is under its column's name:
users.id becomes id.
Filters
Filters are an allowlist too:
| Filter | URL |
|---|---|
filter.select(column, options) | ?filter[locale]=de. Anything but one of options is a 400. |
filter.dateRange(column) | ?filter[joined]=2026-01-01..2026-03-31, both days included. Either end may be empty. |
A filter takes .visible(…) like a column. A filter on the column of a hidden column is hidden with it, because the number of rows
it leaves would tell that column's values.
Sorting and pages
defaultSort is the sort without a ?sort=: an alias, with - in front for descending. Without it, the table sorts by its first
sortable column that everyone sees. The key always breaks ties. A default sort by a column with .visible(…) is refused, as a
compile error and when defineTable() runs, because it would answer 400 to everyone who may not see the column.
pagination pages by number with a total, by default. perPage lists the page sizes ?per_page= allows, and the first one is the
default: [25, 50, 100] unless you say otherwise. { cursor: true } pages by an encrypted cursor instead, for "load more", without
counting every row. The pagination page explains both.
Who may see the table
authorize decides whether the user may see the table at all. It is asked on the page and in every endpoint. can() of
@marmeon/auth takes a policy and one of its abilities, here an ability viewAny that you add to the policy:
authorize: can(MemberPolicy, 'viewAny'),The endpoints run the middleware of the page's routes, but not the authorize of the page's controller. A table that needs a
check says so here.
More than one table on a page
prefix: 'teams' puts a prefix before the table's keys in the URL: teams_sort, teams_filter[role]. Spread both tables' query
schemas into the page's request:
defineRequest({ query: r.query({ ...MembersTable.query.entries, ...TeamsTable.query.entries }) }).
The controller and the routes
The page's controller validates the table's state as its query and resolves the table as a prop:
import type { Authenticated } from '@marmeon/auth';
import { Controller, defineRequest, type ContextOf } from '@marmeon/http';
import { Tables } from '@marmeon/table';
import { MembersTable } from '../tables/MembersTable.ts';
export const ListMembersRequest = defineRequest({ query: MembersTable.query });
export class ListMembersController extends Controller {
static request = ListMembersRequest;
readonly #tables: Tables;
constructor(tables: Tables) {
super();
this.#tables = tables;
}
handle(ctx: ContextOf<typeof ListMembersRequest> & Authenticated) {
return this.view('members/Index', { members: () => this.#tables.resolve(MembersTable, ctx) });
}
}The prop is a function, so a partial reload of another prop leaves the table out. tableRoutes() registers the table's endpoints
on the registrar you pass, the page's:
import { authenticate, verified } from '@marmeon/auth';
import { throttle } from '@marmeon/cache';
import { defineRoutes } from '@marmeon/http';
import { tableRoutes } from '@marmeon/table';
import { ListMembersController } from './controllers/ListMembersController.ts';
import { MembersTable } from './tables/MembersTable.ts';
export default defineRoutes((Route) => {
const members = Route.prefix('/members').middleware(authenticate()).middleware(verified()).name('members.');
members.get('/', ListMembersController).name('index');
tableRoutes(members.middleware(throttle('members-table')), MembersTable);
});| Route | Body | Answer |
|---|---|---|
POST /members/_tables/members/actions/:tableAction | { key } | { done, skipped, missing, data? } |
POST /members/_tables/members/bulk/:tableBulk | { keys: [...] } or { all: true, query } | { done, skipped, missing, data? } |
GET /members/_tables/members/export/:tableExport | none: the table's state in the query | a CSV file |
The endpoints are ordinary routes of the page's group, with its middleware, its route bindings, the session and CSRF protection
for the posts. A guest or an unconfirmed account never reaches them. A registrar that lacks what the base query needs, such as
ctx.user without authenticate(), is a compile error. The routes have no names, because the props carry their URLs. Throttle
them: an export reads the whole filtered table. The rate limiting page shows the members-table limiter.
What the browser gets
tables.resolve(MembersTable, ctx) resolves to TableProps<typeof MembersTable>:
{
name: 'members', key: 'id',
columns: [{ name: 'name', label: null, sortable: true, searchable: true, sort: 'asc' }, …],
rows: [{ id: 7, name: 'Ada', joined: '2026-01-10T09:00:00.000Z', can: { removeLinks: true } }, …],
sort: { column: 'name', direction: 'asc' },
search: { value: '' },
filters: [{ name: 'locale', type: 'select', key: 'filter[locale]', options: ['en', 'de'], value: null }, …],
page: { type: 'offset', page: 1, perPage: 10, perPageOptions: [10, 25, 50], total: 42, lastPage: 5, from: 1, to: 10 },
query: { sort: 'sort', page: 'page', perPage: 'per_page', cursor: 'cursor', search: 'search' },
state: { sort: '-joined', 'filter[locale]': 'de' },
actions: [{ name: 'removeLinks', url: '/members/_tables/members/actions/removeLinks', confirm: true }],
bulk: [{ name: 'removeLinks', url: '/members/_tables/members/bulk/removeLinks', confirm: true, max: 100 }],
exports: [{ name: 'csv', format: 'csv', url: '/members/_tables/members/export/csv?sort=-joined&filter%5Blocale%5D=de', max: 10000 }],
}columnsholds the visible columns, in the definition's order.- Each row has the key and the visible columns, nothing else.
canholds one boolean per row action, computed for the whole page with one policy call per ability, never a policy's reason. searchisnullwhen no visible column is searchable.stateis the table's state in the URL, without defaults.- Actions, bulk actions and exports the user may not use are not in the props at all.
The URLs carry the page's own route parameters: a table under /teams/:team links to /teams/7/_tables/….
The URL is strict
MembersTable.query is the table's state as a query schema: sort, page or cursor, per_page, search when a column is
searchable, and filter[<name>], with the table's prefix. It is typed, so ctx.query.sort is 'name' | '-name' | 'joined' | ….
Other query schemas fall back per field: a link with a broken value shows the default. A table does not. Each of these is a 400:
- a sort that is not offered, or a raw column name:
?sort=password,?sort=created_atinstead ofjoined, - a filter that does not exist,
?filter[password]=x, or a value its filter does not offer, - a page size that is not in
perPage,?per_page=10000, or a page that is no whole number, - a repeated key, or a search longer than 100 characters,
- sorting by a column the user cannot see, or a filter the user cannot see,
- a search when no column the user can see is searchable.
The search never looks into a column the user cannot see. It leaves such a column out without an error, so a search finds nothing in a hidden column and tells nothing about it.
Nothing from the URL reaches ORDER BY or WHERE that the definition did not name. A sort or a filter is an alias that is looked
up, and only its column goes into the query.
Actions
Row actions
A row action is a class with handle(row, ctx), built per request through the container:
import { ProfileLinkRepository } from '#modules/user-profile';
export class RemoveMemberLinks {
readonly #links: ProfileLinkRepository;
constructor(links: ProfileLinkRepository) {
this.#links = links;
}
async handle(member: { readonly id: number }) {
return { removed: await this.#links.removeAllOf([member.id]) };
}
}action(Handler) puts it into the table. .visible(Policy, 'ability') offers it only to some users, asked once per request.
.authorize(Policy, 'ability') asks the policy for each row, with the row as the table hands it out. .confirm() makes the
browser ask first. The endpoint checks, in this order:
- the table's
authorize, else 403, - that the action exists, else 404, and is visible, else 403,
- the body,
- that the row is in the base query: another tenant's row, a deleted one or a made-up key is a 404,
- the action's policy for that row, else 403.
Only then does handle() run. A policy that decides per row sees the key and the visible columns, by alias. One that needs more
looks the row up by its key, or the column becomes part of the table.
Bulk actions
A bulk action is a class with handle(rows, ctx). It acts on the chosen keys ∩ the base query ∩ the policy per row. Keys that
are no rows of the table are left out and counted as missing, and rows the policy refuses as skipped. The handler gets only the
rest, and none of it runs when nothing is left.
"All matching" sends the table's state, { all: true, query }, never a list of keys. The server parses it again, as strictly as
the URL, and refuses more rows than the action's max, 1000 by default, with a 422. It never acts on a list that was cut short.
Chosen keys are held to max as well.
The answer
An action request, the kind useTable() sends, and an API client get JSON: { done, skipped, missing }, and data with what the
handler returned. The answer is never cached. A form posted without JavaScript, or a page visit, goes back to the page with a 303.
A handler that returns a redirect, a JSON result or a Response answers for itself.
Exports
exportCsv() downloads the visible columns, filtered and sorted as the URL says:
- Its own
authorize. Without one, whoever sees the table may download what they see. - A limit.
exportCsv().max(rows), 10000 by default. One count runs first, and an export over the limit is a 422 before a byte is sent, never a file cut short. - Batches. Rows stream in batches of 500. The first batch runs in the handler, and the later ones after it has answered: in the request's context, for the log and the request id, and outside any transaction that was open around the handler. Each batch goes on after the last row written, by the table's order and then the key, never at an offset. A row deleted meanwhile moves no later row out of the file, and a row added meanwhile shows at most once.
- Headers.
Content-Disposition: attachmentwith a safe file name such asmembers-2026-10-06.csv,Cache-Control: no-store,X-Content-Type-Options: nosniff, and a byte order mark, so spreadsheets read umlauts right. - Formula injection is escaped. A text cell that starts with
=,+,-,@, a tab, a carriage return or a line feed gets a'in front. Every field is quoted, with quotes doubled. Numbers stay numbers, so-5is no formula.
The security model
Every endpoint starts again at the beginning. Nothing the browser sends is trusted, whatever the page showed:
| Attack | What stops it |
|---|---|
| A hidden column read anyway, in the props, the devtools' query log, a cursor or the CSV | Two measures: the projection selects only the visible columns and the key, so the column is not in the SQL, and the props pick only the visible columns by name, even when the base query selects more. |
| Sorting by a column you may not see: the order alone tells its values | The allowlist: a sort, a filter, a search or a page size the definition does not offer is a 400, never a fallback. Sorting or filtering by a hidden column is a 400 too, and the search never looks into one. |
| Another tenant's rows | The base query: a row action looks its row up there, a bulk action intersects with it, an export reads from it. |
| An endpoint without the page's protection | tableRoutes() runs on the page's registrar, with its middleware. A registrar without what the base query needs does not compile. |
| Keys of rows you may not touch, in a bulk action's body | Keys ∩ base query ∩ policy per row: what is left out is counted, never acted on. |
| "All matching" as a huge or forged list | The state is sent, never keys, and parsed again. More rows than max is a 422. |
A cell a spreadsheet runs, such as =HYPERLINK(…) | Escaping of every text cell that starts like a formula. |
| An export as a denial of service | max before the first byte, an authorize of its own, and a throttle() on the table's routes. |
| A key of the wrong type, such as text for an integer column | Key types: every key is parsed by its type before any query, and one that does not parse is no row. |
The security page puts the table's model next to the app's other defaults.
In the browser
useTable(props.members) from @marmeon/react/table gives a component what the props allow, typed by them: only sortable columns
sort, only the table's filters filter, only its actions run. The markup is yours:
import type { PageProps } from '@marmeon/http';
import { useTranslation } from '@marmeon/react';
import { useTable } from '@marmeon/react/table';
import type { ListMembersController } from '../controllers/ListMembersController.ts';
export default function Index({ members }: PageProps<ListMembersController>) {
const t = useTranslation('members');
const table = useTable(members, {
labels: { column: (name) => t(`column.${name}`), row: (row) => row.name, action: (_action, name) => t('remove_links_of', { name }) },
});
const csv = table.exports.find((entry) => entry.name === 'csv');
return (
<main>
<p {...table.statusProps}>{table.status}</p>
<table>
<caption>{t('caption')}</caption>
<thead>
<tr>
<th scope="col">
<input {...table.pageCheckboxProps()} />
</th>
{table.columns.map((column) => (
<th key={column.name} {...column.headerProps}>
{column.sortLinkProps ? <a {...column.sortLinkProps}>{column.label}</a> : column.label}
</th>
))}
<th scope="col">{t('actions')}</th>
</tr>
</thead>
<tbody>
{table.rows.map((row) => (
<tr key={row.id}>
<td>
<input {...table.checkboxProps(row)} />
</td>
<th scope="row">{row.name}</th>
<td>{row.email ?? ''}</td>
<td>{row.joined}</td>
<td>
{table.can('removeLinks', row) && (
<button type="button" aria-label={table.actionLabel('removeLinks', row)} onClick={() => void table.run('removeLinks', row)}>
{t('remove_links')}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
<button type="button" onClick={() => void table.runBulk('removeLinks')}>
{t('bulk_remove', { count: table.selectedCount })}
</button>
{csv && (
<a href={table.exportUrl('csv')} download>
{t('export')}
</a>
)}
</main>
);
}The example assumes the e-mail column is visible. The web app's page renders each cell from table.columns, so a column the user
cannot see leaves no empty cell.
- Sorting, filtering, searching and paging are visits of the page's URL with the props' keys and the columns' aliases:
table.sort('joined'),table.filter('locale', 'de'),table.filterBy({ … }),table.setSearch('ada'),table.goTo(2),table.perPage(25)andtable.next()for a cursor. Only the table's prop reloads, and the server parses the state again. Asking for something the props do not offer throws before a request goes out, with an error code from[marmeon:C9]to[marmeon:C12]. Sorting, filtering and paging add a history entry. The search waits 300 milliseconds after the last keystroke and replaces the current history entry instead of adding one. Back after a search therefore returns to the entry before it: an earlier sort, filter or page, or the page you came from when the search was the table's first change. Sort links are real links: without JavaScript, or opened in a new tab, they work.table.page.typeisoffsetorcursor, so a page knows which pagination to render. - The selection lives with the history entry, in memory, never in
history.stateor the browser's storage. A new sort, filter or page starts with nothing selected, and Back brings the old selection back. Leaving the document, by signing out or after a new build, forgets it.table.selectMatching()selects every matching row, not only those on screen. - Actions are action requests:
table.run(action, row)andtable.runBulk(action). Afterwards the table's prop reloads and replaces what the page had, also a merged table. A bulk action clears the selection. The action's code loads with the table's first run, in a chunk of its own. An action with.confirm()asks first, withlabels.confirmor the run'sconfirmoption. table.exportUrl('csv')is the download's URL with the table's current sort, search and filters. Use it in a link withdownload, not in a visit.
The web app's page works without JavaScript as well: sort links are links, the filters are a GET form, and the row and bulk actions
are plain forms that come back with a 303. The table's browser code is in the chunk of the page that imports useTable(), so an
app's first download carries none of it unless its first page shows a table.
Accessible markup
useTable() hands out the attributes for markup that follows the WAI-ARIA Authoring Practices for sortable tables:
- A native
<table>with a<caption>that names it. Norole="grid"without real cell navigation by arrow keys. aria-sortonly on the heading the table is sorted by, throughheaderProps. Nevernoneon the others.- The sort control is a link or a button inside the
<th>, named after its column:sortLinkPropsortoggleSort(). - The cell that names the row is a
<th scope="row">, so a screen reader says whose value a cell holds. - The number of rows, with the sort and the page, goes into a live region:
statusPropsandstatus. What an action did goes into a second one, because the list's count often stays the same. - Checkboxes and row buttons are named after their row. A button's name starts with its visible text, "Remove links: Ada Lovelace" for a button that says "Remove links", so a voice user can say what they see.
- Focus is never dropped. A control that cannot be used right now stays focusable, with
aria-disabledand a click that does nothing, instead ofdisabled. A confirmation dialog gives focus back to the button that opened it.
TanStack Table
An app that wants TanStack Table's column model, sizing or pinning keeps the server in charge with a bridge:
import type { PageProps } from '@marmeon/http';
import { useTable } from '@marmeon/react/table';
import { toTanStack } from '@marmeon/react/table-tanstack';
import { rowPaginationFeature, rowSelectionFeature, rowSortingFeature, tableFeatures, useTable as useTanStackTable } from '@tanstack/react-table';
import type { ListMembersController } from '../controllers/ListMembersController.ts';
const features = tableFeatures({ rowSortingFeature, rowPaginationFeature, rowSelectionFeature });
export default function Grid({ members }: PageProps<ListMembersController>) {
const table = useTable(members);
const tanstack = useTanStackTable(toTanStack(table, { features }));
return <p>{tanstack.getRowModel().rows.length}</p>;
}TanStack runs in manual mode: it sees one page and sorts nothing itself, and its sorting, paging and row selection go back through
useTable(). Install TanStack yourself with pnpm add @tanstack/react-table@^9. The bridge imports only its types, so TanStack
is in a bundle only where the app imports it. Define features at module scope, so it stays the same object between renders.
Type checks
Mistakes in a definition are compile errors with readable messages:
'emial' is not a column of the table's query — it has avatar_path, bio, created_at, email, …
'password' is a secret column — a table never reads it (its migration marked it .secret())
the key 'email' holds text — say how to read it: key: { column: 'email', type: 'string' } (or 'uuid', 'bigint')
'-email' is not a sortable column — it sorts by -name, name
'deactivat' is not an ability of this policy — it has deactivate, export, …The same goes for a default sort by a hidden column, an action whose handler takes other rows than the table's, a .visible()
ability that wants a row, and tableRoutes() on a registrar that lacks what the base query needs. A handler's ctx is not compared
with the base query's context, so type it as TableContext<…> like the query.
Testing
A test runs an action the way the browser does, and downloads an export of any length:
import { UserRepository, type User } from '#modules/auth';
import { createTestApp } from '@marmeon/testing';
import { it } from 'vitest';
import application from '../../bootstrap/app.ts';
it('a member who is no administrator can neither sort by e-mail nor export', async () => {
const app = await createTestApp(application, { database: 'refresh', seed: true });
const [, alan] = (await app.make(UserRepository).query().selectAll().orderBy('id').execute()) as User[];
await app.actingAs(alan!).navigating().get('/members?sort=password').assertStatus(400);
await app.actingAs(alan!).navigating().get('/members?sort=email').assertStatus(400);
await app.actingAs(alan!).get('/members/_tables/members/export/csv').assertForbidden();
});app.actingAs(user).acting().post(url, { json: { key } }) sends a row action as useTable() would. The HTTP
tests page covers requests in tests.