Data and backends

Server-side data adapters plus ready-made list executors for PostgreSQL, MongoDB and Mongoose.

Async data (server-side)

import { serverActionAdapter } from 'listkit'

const adapter = serverActionAdapter<Product>(async query => {
  const { rows, total } = await listProductsAction(query) // page/pageSize/search/filters
  return { data: rows, total }
})

<ListView config={productsConfig} adapter={adapter} />

PostgreSQL backend (listkit/sql)

For a Postgres backend, listkit/sql turns a ListQuery into safe SQL fragments — $n placeholders, lower() LIKE, NULLS LAST — with no driver dependency. Compose them yourself, or hand a pool to executeSqlList for the whole page query (filters + search + scope + sort + pagination) in one call:

import { parseListkitQuery } from 'listkit/query'
import { executeSqlList } from 'listkit/sql'

app.get('/api/discounts', async (req, res) => {
	const { data, total } = await executeSqlList<Discount>({
		pool, // node-postgres / @vercel/postgres / @neondatabase/serverless — any { query() }
		table: 'discount d',
		query: parseListkitQuery(req.query),
		fields: {
			kind: 'd.kind', // select  → equality
			value: 'd.value', // number-range → >= / <=
			created: 'd.created_at', // date-range
			// many-to-many via a `match` builder + the `p(value)` placeholder factory:
			colors: {
				match: (v, p) =>
					Array.isArray(v) && v.length
						? `EXISTS (SELECT 1 FROM product_color j WHERE j.sku = d.sku AND j.id = ANY(${p(v.map(Number))}::int[]))`
						: null,
			},
		},
		searchColumns: ['d.label', 'd.code'],
		sort: { label: 'd.label', created: 'd.created_at' },
		fallbackSort: 'd.created_at DESC',
		tiebreak: ', d.id DESC',
		scope: { 'd.tenant_id': tenantId }, // auth scope merged into every query
	})
	res.json({ data, total }) // the { data, total } shape fetchAdapter expects
})

Columns come only from the whitelists you control (no SQL injection), and matching mirrors the in-memory adapter. For full control, drop to buildSqlFilter(query, fields, params) + buildSearch(term, columns, params) (both append to your params so $n numbering stays correct) and buildOrderBy — exactly the manual pattern, minus the boilerplate. sqlFieldMapFromFilters(config.filters) derives a starting field map from your list config.

executeSqlList also takes searchNormalizer — the fold applied to both the column and the bound term, so expr => `unaccent(lower(${expr}))` makes "Mexico" find "México" (needs the unaccent extension); the term binds raw precisely so the fold can reach it. And maxExport honors an oversized pageSize as an export-all instead of clamping it to one page, mirroring mongoPaginate. Pass the same searchNormalizer to buildSqlExport so an export returns exactly the rows the list showed.

MongoDB backend (listkit/mongo)

The front-end is the same in any React app (fetchAdapter → your REST endpoint). On the server, translate the incoming ListQuery into plain Mongo objects with listkit/mongo — it has no mongoose/driver dependency and never runs a query, so it works with Mongoose or the native driver. Field names come only from whitelists you control (no NoSQL injection), and text values are regex-escaped.

import { buildMongoQuery } from 'listkit/mongo'

// query is the listkit ListQuery parsed from the request
const { filter, sort, skip, limit } = buildMongoQuery(query, {
	fields: {
		legalName: 'legalName', // text  → case-insensitive $regex
		type: 'type', // select → equality
		status: 'csf.generalData.status', // nested path, dispatched by filter type
		created: 'createdAt', // date-range → $gte/$lte
		hasCsf: { path: 'csf', build: existenceMatch }, // one field, custom expr
		// Computed bucket over several fields — `match` is merged as-is:
		certStatus: {
			match: v =>
				v === 'active'
					? { cerFile: { $ne: null }, certificateValidTo: { $gt: new Date() } }
					: null,
		},
	},
	sort: { name: 'legalName', created: 'createdAt' },
	fallbackSort: { legalName: 1 },
})

const [data, total] = await Promise.all([
	Model.find(filter).sort(sort).skip(skip).limit(limit).lean(),
	Model.countDocuments(filter),
])
return { data, total } // the { data, total } shape fetchAdapter expects

Matching mirrors the in-memory engine. Text, select and multi-select compare accent- and case-insensitively ('cancun' finds 'Cancún'), and a false boolean also matches documents where the field was never written — the same rows a memoryAdapter would return, enforced by a parity suite that runs one fixture through both engines against a real mongod.

Two escape hatches matter at scale:

fields: {
	// Controlled values on an indexed field: exact equality, index-friendly.
	status: { path: 'status', fold: false },
	// Dates stored as Date.now() numbers instead of BSON Dates.
	created: { path: 'createdAt', as: 'unix-ms' },
}

A folded comparison is a regex, so it cannot use an equality index. For accent-insensitive equality at scale, prefer a collation index ({ locale: 'es', strength: 1 }) and pass collation to the executor. Free-text search is a non-anchored regex by nature: keep searchFields short, pair it with an indexed baseFilter (a tenant, an owner), and move to Atlas Search once that stops being enough.

One call end to end. executeMongoList assembles filters, search, references, sort and pagination and runs the find + count. It is driver-free — pass the native collection or a Mongoose model's .collection:

import { executeMongoList } from 'listkit/mongo'
import { parseListkitQuery } from 'listkit/query'

app.get('/api/companies', async (req, res) => {
	const result = await executeMongoList({
		collection: db.collection('companies'),
		query: parseListkitQuery(req.query),
		fields: mongoFieldMapFromFilters(companiesConfig.filters ?? []),
		searchFields: ['legalName', 'taxId'],
		sort: { name: 'legalName', created: 'createdAt' },
		fallbackSort: { legalName: 1 },
		tiebreak: { _id: 1 }, // ties would otherwise paginate non-deterministically
		baseFilter: { organizationId: req.orgId },
	})
	res.json(result) // { data, total }
})

Filters on a joined collection. resolveReferences turns "filter sales by their customer's name" into an $in of matching ids, capped (10 000 by default) so a broad filter can't pull a whole collection into one query; buildMongoSearchWithRefs does the same for free-text search. /mongoose wires both for you via references / searchReferences.

Migrating an existing endpoint. If your API already answers { results, pagination }, keep that contract while you move the internals: wrap with toLegacyEnvelope on the server, and read it with fromLegacyEnvelope as the adapter's transformResponse until the wire itself is migrated. encodeListQuery is the canonical client encoding, exported so a custom adapter can't drift from parseListkitQuery.

A field map entry is a trusted path string, { path, build } to customize the expression for one field, or { match } to build a complete condition merged as-is — the latter is how a single filter spans several fields (computed buckets, cross-field rules). Compose extra conditions (auth scope, tenant id, a reference $in from a nested-collection lookup) with combineFilters, and reach for the lower-level buildMongoFilter / buildMongoSort / mongoPaginate / existenceMatch helpers when you need finer control.

Skip the second copy. Instead of hand-writing the fields whitelist, derive it from the same filters your list config already declares with mongoFieldMapFromFilters — so the sidebar UI and the backend query stay in sync from one source. Existence selects (options with/without) map to an existenceMatch spec automatically. For filters that target a populated/joined collection, use filterConfigToMongoFieldMaps(filters, { references }) to split them into { main, refs }:

import {
	buildMongoQuery,
	filterConfigToMongoFieldMaps,
	mongoFieldMapFromFilters,
} from 'listkit/mongo'

// Simple case — one collection:
const fields = mongoFieldMapFromFilters(companiesConfig.filters ?? [])
const { filter, sort, skip, limit } = buildMongoQuery(query, {
	fields,
	sort: sortMap,
})

// With a populated reference (e.g. `csf.*` lives on a joined collection):
const { main, refs } = filterConfigToMongoFieldMaps(
	companiesConfig.filters ?? [],
	{
		references: { csf: 'csf' },
	}
)
// → main = company-level filters; refs.csf = filters on the csf collection

Mongoose executor (listkit/mongoose)

For a Mongoose backend, listkit/mongoose runs the whole page query for you — search, advanced filters, populated references, sort, pagination, and an export-all path — so a controller is a few lines. mongoose is an optional, type-only peer dependency (imported with import type, so this entry ships no mongoose runtime and adds zero bundle weight beyond the builders); install it in the backend to use this entry.

import { parseListkitQuery } from 'listkit/query'
import { filterConfigToMongoFieldMaps } from 'listkit/mongo'
import { executePaginatedListkitQuery } from 'listkit/mongoose'

const maps = filterConfigToMongoFieldMaps(companiesConfig.filters ?? [], {
	references: { csf: 'csf' },
})

app.get('/api/companies', async (req, res) => {
	const { data, total } = await executePaginatedListkitQuery<Company>({
		model: CompanyModel,
		query: parseListkitQuery(req.query),
		fields: maps.main,
		references: [{ path: 'csf', model: CsfModel, fields: maps.refs.csf ?? {} }],
		searchFields: ['legalName', 'taxId'],
		searchReferences: [
			{ path: 'csf', model: CsfModel, fields: ['generalData.postalCode'] },
		],
		sortFields: { name: 'legalName', created: 'createdAt' },
		fallbackSort: { legalName: 1 },
		populate: ['csf'],
		baseFilter: { appsAllowed: req.app }, // auth scope, tenant id, …
	})
	res.json({ data, total }) // the { data, total } shape fetchAdapter expects
})

Each active reference filter becomes a $in of the matching reference ids; the search term matches searchFields on the main collection and (by id) searchReferences. A pageSize greater than maxPageSize (default 100) is treated as export all — served from the first row, capped at maxExport (default 50 000) — so it pairs with a list's export fetchAll. When you don't need references/populate, the lower-level buildMongoQuery + your own Model.find is still the simplest path.

For rows a find can't express — $unwinded documents, a $lookup join, an $addFields column the user filters and sorts by — executeAggregateListkitQuery is the sibling executor: the same options plus your pipeline. It reuses the very same builders, so search/filter/sort semantics are identical including value casting. That last part is not free: an aggregation $match casts nothing on its own, unlike find, so every $match is run through the model's schema first (castFilterToSchema, exported if you build pipelines by hand). A value the schema rejects — a malformed id from a stale bookmark — resolves to a filter no row satisfies, so the list comes back empty rather than unfiltered.

On this page