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 whose render is JSX. Falls back to item[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 wire fetchAll. listkit never loops your adapter page-by-page; point fetchAll at a dedicated bulk/stream endpoint that applies the current query server-side. The button shows a spinner while it runs.
  • Set allowExportAll: false to 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 value reads its key as a dot path, traversing arrays (products.name → every product name, joined with '; '; override per field with join).
  • Dates (and ISO strings from a server) render YYYY-MM-DD in local time — spreadsheet-sortable, and no off-by-one day near midnight. Choose 'datetime'/'iso'/a custom function via dateFormat (export-wide) or date (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.

On this page