Export
CSV export out of the box, and a configurable export contract for scope, fields and column order.
CSV export
Add a toolbar export button with export. It exports the visible columns in their current order (so column hide/reorder is respected), the current page by default:
defineListConfig<Product>({
export: true, // current-page CSV button
table: {
columns: [
{ key: 'name', header: 'Name' },
// render returns JSX → give a plain value to serialize:
{
key: 'price',
header: 'Price',
render: p => <b>{money(p.price)}</b>,
exportValue: p => p.price,
},
{ key: 'actions', header: '', render: rowActions, exportable: false }, // skipped
],
},
})exportValue?(item)— plain value for a column whoserenderis JSX. Falls back toitem[key](dot-paths supported).exportable: false— exclude a column (e.g. an actions column).
Export all. Pass an ExportConfig to also offer an "export all" choice:
export: {
fileName: 'products', // defaults to the list id
fetchAll: query => listAll(query), // server bulk endpoint (gets the live query)
}- In-memory
data— "export all" is offered automatically (everything is already in the browser). - Async
adapter— "export all" appears only when you wirefetchAll. listkit never loops your adapter page-by-page; pointfetchAllat a dedicated bulk/stream endpoint that applies the current query server-side. The button shows a spinner while it runs. - Set
allowExportAll: falseto force current-page-only.
CSV is native (no extra dependency) and UTF-8 BOM-prefixed so Excel reads accents correctly. The helpers exportRowsToCsv / rowsToCsv / downloadCsv are exported for custom buttons.
Configurable export (scope, fields, order)
With export enabled, "Export…" opens a configuration dialog before generating
the file: the user picks the scope (current page / selected rows / all
matching results), which fields to include — pre-checked to the columns
currently visible — and their order. Set export.configurable: false to
restore the one-click menu.
The export universe defaults to the table's export-eligible columns. Declare
fields to offer properties the table never renders, grouped Stripe-style:
export: {
fileName: 'orders',
groups: [
{ id: 'order', label: 'Order' },
{ id: 'customer', label: 'Customer' },
],
fields: [
{ key: 'reference', label: 'Reference', group: 'order' },
{ key: 'total', label: 'Total', group: 'order', value: o => o.total },
{ key: 'placedAt', label: 'Date', group: 'order' }, // → YYYY-MM-DD, local time
{ key: 'customer.name', label: 'Customer', group: 'customer' },
{ key: 'customer.taxId', label: 'Tax id', group: 'customer' }, // not a column
{ key: 'products.name', label: 'Products', group: 'customer' }, // array → "A; B"
],
},- A field without
valuereads itskeyas a dot path, traversing arrays (products.name→ every product name, joined with'; '; override per field withjoin). - Dates (and ISO strings from a server) render
YYYY-MM-DDin local time — spreadsheet-sortable, and no off-by-one day near midnight. Choose'datetime'/'iso'/a custom function viadateFormat(export-wide) ordate(per field). maxRows(default 50,000) caps an "all" export; a truncated file says so in the dialog — never silently.
Selecting beyond the page. Once a whole page is selected, the selection bar
offers "Select all N matching results" — a virtual selection (no rows are
loaded). Unchecking rows accumulates exclusions; the export sends
excludeKeys instead of materializing anything.
Scaling with a resolver. For a server-backed list, wire export.resolve —
it receives the whole ExportRequest (scope, query, ordered field keys,
include/exclude keys) and returns rows (never a pre-built file, so
per-field formatting is identical for every scope):
// client
export: {
resolve: async request => {
const res = await fetch('/api/orders/export', {
method: 'POST', // recommended: filter values are often PII, and key lists outgrow URLs
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(exportRequestToBody(request)),
})
return res.json() // { rows, truncated?, total? }
},
},
// server (Express-style) — Mongo:
const request = parseExportRequest(req.body, { fields: EXPORT_KEYS })
if (!request) return res.status(400).end()
const { filter, sort, projection, skip, limit } = buildMongoExport(request, {
fields: FIELDS, // same whitelist as the list endpoint
exportPaths: EXPORT_PATHS, // field key → trusted Mongo path
tiebreak: { _id: 1 }, // required: exports need a total order
})
const rows = await Model.find(filter, projection).sort(sort).skip(skip).limit(limit).lean()
res.json({ rows, total: await Model.countDocuments(filter) })
// server — Postgres:
const { sql, params } = buildSqlExport(request, {
table: 'orders o',
fields: FIELDS,
exportColumns: {
reference: 'o.reference',
'products.name': { relation: { table: 'order_item i', on: 'i.order_id = o.id', column: 'i.product_name', orderBy: 'i.pos' } },
},
fallbackSort: 'o.created_at DESC, o.id DESC',
tiebreak: ', o.id',
idColumn: 'o.id',
})
const { rows } = await pool.query(sql, params)Key lists bind as one = ANY($n) parameter (an IN ($1, $2, …) with
thousands of keys overruns the driver's parameter limit). Legacy fetchAll
keeps working as a scope: 'all' resolver.
Without a resolver: in-memory lists support all three scopes natively; a
server adapter without resolve offers page + selected only, with "all"
disabled with an explanation in the dialog.