Server-side Data with Express.js
This tutorial shows how to wire Handsontable’s dataProvider plugin to an Express.js 4 backend. The backend provides paginated, sorted, and filtered server-side data with full CRUD operations using PostgreSQL via TypeORM and Zod for request validation.
Overview
View full example on GitHubThis recipe shows how to connect Handsontable’s dataProvider plugin to an Express.js 4 backend. You will build a support-tickets grid that loads data from a REST API with server-side pagination, sorting, and filtering, and that persists row create, update, and delete operations to a PostgreSQL database via TypeORM.
Difficulty: Beginner–Intermediate
Time: ~30 minutes
Stack: Express.js 4, TypeScript, TypeORM 0.3, PostgreSQL 16, Zod, Handsontable dataProvider
What You’ll Build
A support-tickets data grid that:
- Fetches paginated rows from an Express REST API on every page, sort, or filter change
- Applies filters on the server using TypeORM QueryBuilder predicates — the browser never loads the full dataset
- Creates, updates, and deletes rows via dedicated endpoints
- Serializes Handsontable’s sort and filter objects as bracket-notation query parameters — decoded by Zod’s
safeParseusingz.coerce - Seeds the database with 12 realistic support tickets via a TypeORM migration
Before you begin
- Docker and Docker Compose installed
- Node.js 18 or later and npm installed
- Basic familiarity with Express.js routing and middleware
Start the project
Run the one-command bootstrap script, which starts PostgreSQL, runs TypeORM migrations, seeds 12 sample tickets, starts the Express backend, and opens the Vite dev server:
Terminal window bash setup.sh# or: make setupThe project uses Zod for request validation:
What’s happening:
z.coerce.number()converts query-string values — which are always strings — into numbers. For example,page=2in the query string becomes the number2.- Zod’s
safeParsevalidates those coerced values against the schema constraints such as.int().min(1), and returns an error object instead of throwing, so the router can respond with a400before the request reaches the service. - The
z.union([z.array(...), ...]).transform(...)onfiltersnormalises both the single-filter and multi-filter shapes that Express parses from bracket notation into a consistent array.
Together these give you end-to-end type safety from the HTTP request all the way to the TypeScript service method without a DI container or decorator metadata.
Define the data model
Copy
ticket.entity.tsintosrc/:TypeScript import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';export type TicketStatus = 'open' | 'in-progress' | 'resolved' | 'closed';export type TicketPriority = 'low' | 'medium' | 'high' | 'critical';@Entity('tickets')export class TicketEntity {@PrimaryGeneratedColumn('uuid')id: string;@Column()subject: string;@Column({ default: 'open' })status: TicketStatus;@Column({ default: 'medium' })priority: TicketPriority;@Column()assignee: string;@Column({ name: 'created_at' })createdAt: string;}What’s happening:
TicketStatusandTicketPriorityare union types that match thesourcearrays in the Handsontable column definitions. Sharing these types between server and client prevents mismatched values.TicketEntityis a TypeORM entity backed by a PostgreSQL table. The@Entity('tickets')decorator maps the class to theticketstable. Twelve seed rows are inserted by a migration so pagination and filtering are meaningful from the first load.- The
idfield is a UUID string generated by PostgreSQL (gen_random_uuid()). Handsontable’sdataProvider.rowIdoption identifies rows by string, so a UUID id requires no conversion.
Define validation types with Zod
Copy
types.tsintosrc/:TypeScript import { z } from 'zod';const ALLOWED_COLUMNS = ['id', 'subject', 'status', 'priority', 'assignee', 'createdAt'] as const;const ALLOWED_CONDITIONS = ['eq', 'neq', 'contains', 'not_contains', 'begins_with', 'ends_with', 'empty', 'not_empty'] as const;const filterSchema = z.object({prop: z.enum(ALLOWED_COLUMNS),condition: z.enum(ALLOWED_CONDITIONS),value: z.array(z.string()).default([]),});const sortSchema = z.object({column: z.enum(ALLOWED_COLUMNS),order: z.enum(['asc', 'desc']),});export const fetchQuerySchema = z.object({page: z.coerce.number().int().min(1).default(1),pageSize: z.coerce.number().int().min(1).default(10),sort: sortSchema.optional(),filters: z.union([z.array(filterSchema), filterSchema]).transform((v): z.infer<typeof filterSchema>[] => (Array.isArray(v) ? v : [v])).optional(),});What’s happening:
z.coerce.number()onpageandpageSizeQuery-string values arrive as strings.
z.coerce.number()converts"2"to2before the.int().min(1)constraints run. Without coercion, validation would reject every request because"2"is a string, not a number.z.union([z.array(filterSchema), filterSchema]).transform(...)onfiltersHandsontable sends one filter object per active column. With a single active filter the query string looks like:
filters[0][prop]=status&filters[0][condition]=eq&filters[0][value][0]=openExpress parses this as
filters = { '0': { prop: 'status', ... } }when only one filter is present and as an array when multiple filters are present. Thez.unionwith.transformnormalizes both shapes into a consistentFilterConditionDto[].ALLOWED_COLUMNSandALLOWED_CONDITIONSenumsUsing
z.enum(ALLOWED_COLUMNS)on thepropfield ensures users cannot inject arbitrary column names into the SQL query builder. Any request with an unrecognised column name is rejected with a400before reaching the service.Bootstrap the server
Copy
main.tsintosrc/:TypeScript import 'reflect-metadata';import express from 'express';import cors from 'cors';import { AppDataSource } from './data-source';import { ticketsRouter } from './tickets.router';const app = express();const PORT = 3000;/*** Enable CORS so the browser can call the API from a different origin.** In production, replace the wildcard with your frontend domain:* app.use(cors({ origin: 'https://your-app.example.com' }));*/app.use(cors());/*** Parse incoming JSON request bodies.* Required for POST, PATCH, and DELETE endpoints that receive JSON payloads.*/app.use(express.json());app.use('/tickets', ticketsRouter);AppDataSource.initialize().then(() => {app.listen(PORT, () => {console.log(`Express server running on http://localhost:${PORT}`);});}).catch((err) => {console.error('Error during Data Source initialization', err);process.exit(1);});What’s happening:
app.use(cors())Without CORS headers, the browser blocks requests from a different origin — for example, a Vite dev server on
localhost:5173calling the Express API onlocalhost:3000.cors()with no arguments allows all origins, which is safe for local development. In production, pass your deployed frontend domain:app.use(cors({ origin: 'https://your-app.example.com' }));app.use(express.json())This middleware parses incoming
Content-Type: application/jsonrequest bodies and exposes the result asreq.body. Without it,req.bodyisundefinedand the POST, PATCH, and DELETE handlers receive no data.AppDataSource.initialize()TypeORM’s
DataSource.initialize()opens the database connection pool and runs any pending migrations before the server starts listening. Startingapp.listen()inside the.then()callback ensures the server only accepts requests after the database is ready.Implement the service
Copy
tickets.service.tsintosrc/:TypeScript import { AppDataSource } from './data-source';import { TicketEntity, TicketPriority, TicketStatus } from './ticket.entity';export interface FetchTicketsParams {page: number;pageSize: number;sort?: { column: string; order: string };filters?: Array<{ prop: string; condition: string; value: string[] }>;}export interface CreateTicketDto {subject: string;status: string;priority: string;assignee: string;createdAt: string;}export interface UpdateTicketDto extends Partial<CreateTicketDto> {id: string;}export class TicketsService {private get repo() {return AppDataSource.getRepository(TicketEntity);}/*** Returns a paginated, sorted, and filtered slice of tickets.** Uses QueryBuilder so filters and sorts translate directly to SQL rather* than loading all rows into memory.*/async findAll(params: FetchTicketsParams): Promise<{ rows: TicketEntity[]; totalRows: number }> {const qb = this.repo.createQueryBuilder('ticket');if (params.filters && params.filters.length > 0) {for (const [i, filter] of params.filters.entries()) {const param = `val${i}`;const col = `ticket.${filter.prop}`;const val = filter.value[0];// Escape LIKE metacharacters so user input is treated literally.const esc = (s: string) => s.replace(/!/g, '!!').replace(/%/g, '!%').replace(/_/g, '!_');const like = `LIKE LOWER(:${param}) ESCAPE '!'`;const notLike = `NOT LIKE LOWER(:${param}) ESCAPE '!'`;switch (filter.condition) {case 'eq':if (val !== undefined) qb.andWhere(`LOWER(${col}::text) = LOWER(:${param})`, { [param]: val });break;case 'neq':if (val !== undefined) qb.andWhere(`LOWER(${col}::text) != LOWER(:${param})`, { [param]: val });break;case 'contains':if (val !== undefined) qb.andWhere(`LOWER(${col}::text) ${like}`, { [param]: `%${esc(val)}%` });break;case 'not_contains':if (val !== undefined) qb.andWhere(`LOWER(${col}::text) ${notLike}`, { [param]: `%${esc(val)}%` });break;case 'begins_with':if (val !== undefined) qb.andWhere(`LOWER(${col}::text) ${like}`, { [param]: `${esc(val)}%` });break;case 'ends_with':if (val !== undefined) qb.andWhere(`LOWER(${col}::text) ${like}`, { [param]: `%${esc(val)}` });break;case 'empty':qb.andWhere(`(${col} IS NULL OR ${col}::text = '')`);break;case 'not_empty':qb.andWhere(`(${col} IS NOT NULL AND ${col}::text != '')`);break;}}}if (params.sort) {qb.orderBy(`ticket.${params.sort.column}`, params.sort.order.toUpperCase() as 'ASC' | 'DESC');} else {qb.orderBy('ticket.createdAt', 'ASC');}const [rows, totalRows] = await qb.skip((params.page - 1) * params.pageSize).take(params.pageSize).getManyAndCount();return { rows, totalRows };}async create(dto: CreateTicketDto): Promise<TicketEntity> {const ticket = this.repo.create({subject: dto.subject,status: dto.status as TicketStatus,priority: dto.priority as TicketPriority,assignee: dto.assignee,createdAt: dto.createdAt ?? new Date().toISOString().slice(0, 10),});return this.repo.save(ticket);}async updateMany(updates: UpdateTicketDto[]): Promise<TicketEntity[]> {const updated: TicketEntity[] = [];for (const { id, ...rest } of updates) {await this.repo.update(id, rest as Partial<TicketEntity>);const ticket = await this.repo.findOneBy({ id });if (ticket) updated.push(ticket);}return updated;}async removeMany(ids: string[]): Promise<void> {if (ids.length > 0) {await this.repo.delete(ids);}}}What’s happening:
Filtering — condition mapping
Handsontable’s Filters plugin sends condition names such as
eq,neq,contains,not_contains,begins_with,ends_with,empty, andnot_empty. Theswitchstatement maps each name to a TypeORM QueryBuilderWHEREclause:case 'contains':qb.andWhere(`LOWER(${col}::text) LIKE LOWER(:${param}) ESCAPE '!'`, { [param]: `%${esc(val)}%` });break;All string comparisons use
LOWER()so filtering is case-insensitive. LIKE metacharacters (%,_,!) in user input are escaped to prevent them from being interpreted as wildcards. Multiple filter conditions are combined withANDusing repeatedandWhere()calls.Sorting
Handsontable sends
sort[column]=status&sort[order]=ascas query params. The Zod schema coerces this into{ column: 'status', order: 'asc' }. The service callsqb.orderBy()with the column name and uppercased direction. Without a sort param, rows fall back tocreatedAt ASC.Pagination
const [rows, totalRows] = await qb.skip((params.page - 1) * params.pageSize).take(params.pageSize).getManyAndCount();Handsontable sends a 1-based
pageindex. Subtracting 1 converts it to a 0-based SQL offset.getManyAndCount()runs the data query and aCOUNT(*)in one round trip.totalRowsis the count of matching rows before pagination — Handsontable uses this number to render the correct number of pages in the pagination bar.ID generation
PostgreSQL generates a UUID for each new row via
DEFAULT gen_random_uuid(). The service callsthis.repo.save(ticket), which runs theINSERTand returns the entity with its database-assignedid. Batch creates callservice.create()once per row insidePromise.all(), so each row receives a unique UUID regardless of timing.createmust return the created rowAfter inserting a row the service returns it with its server-assigned
id. The router passes this return value back to Handsontable viaonRowsCreate. Handsontable replaces the temporary client-side ID with the real one. If the response is empty, subsequent edits and deletes on the new row fail because the grid still holds the wrong ID.Add the router
Copy
tickets.router.tsintosrc/:TypeScript import { Router, Request, Response } from 'express';import { TicketsService, FetchTicketsParams } from './tickets.service';import { fetchQuerySchema } from './types';const service = new TicketsService();export const ticketsRouter = Router();/*** GET /tickets?page=1&pageSize=5&sort[column]=status&sort[order]=asc* &filters[0][prop]=status&filters[0][condition]=eq&filters[0][value][0]=open** Zod's safeParse coerces string query-param values to numbers and validates* the shape before the request reaches the service.*/ticketsRouter.get('/', async (req: Request, res: Response) => {const parsed = fetchQuerySchema.safeParse(req.query);if (!parsed.success) {res.status(400).json({ errors: parsed.error.flatten() });return;}try {const result = await service.findAll(parsed.data as FetchTicketsParams);res.json(result);} catch (err) {res.status(500).json({ error: 'Internal server error' });}});/*** POST /tickets* Body: single CreateTicketDto or array of CreateTicketDto** Handsontable's onRowsCreate callback sends one object per new row.* Wrapping a single object in an array normalises the input for the service.*/ticketsRouter.post('/', async (req: Request, res: Response) => {try {const body = req.body;const rows = Array.isArray(body) ? body : [body];const result = await Promise.all(rows.map((dto) => service.create(dto)));res.status(201).json(result);} catch (err) {res.status(500).json({ error: 'Internal server error' });}});/*** PATCH /tickets* Body: [{ id: 'uuid', status: 'resolved' }, ...]** Each entry contains the row id plus only the changed columns.*/ticketsRouter.patch('/', async (req: Request, res: Response) => {try {const result = await service.updateMany(req.body);res.json(result);} catch (err) {res.status(500).json({ error: 'Internal server error' });}});/*** DELETE /tickets* Body: ['uuid1', 'uuid2', ...]** Handsontable passes an array of row ID strings matching dataProvider.rowId.*/ticketsRouter.delete('/', async (req: Request, res: Response) => {try {await service.removeMany(req.body);res.status(204).send();} catch (err) {res.status(500).json({ error: 'Internal server error' });}});What’s happening:
fetchQuerySchema.safeParse(req.query)validates and coerces the incoming query parameters. If validation fails, the router responds immediately with400and the Zod error details — the service never runs.- The
GEThandler passesparsed.datadirectly toservice.findAll(). Because Zod’ssafeParsereturns a typedParsedDataobject, the service receives correctly typedpage,pageSize,sort, andfiltersvalues. - The
POSThandler wraps a single object in an array to normalise the input: Handsontable may send one new row or several. - The
PATCHhandler receives an array of partial row objects — each includes the rowidplus only the changed columns. - The
DELETEhandler receives an array of ID strings and returns204 No Content. Handsontable only checks for a non-error HTTP status on delete responses.
Endpoint summary:
HTTP method Path Handsontable callback GET/ticketsfetchRowsPOST/ticketsonRowsCreatePATCH/ticketsonRowsUpdateDELETE/ticketsonRowsRemoveWire up Handsontable
Start the backend and the Vite dev server with
bash setup.sh(ormake setup), then openhttp://localhost:5173. The Express API runs onhttp://localhost:3000; Vite proxies all/ticketsrequests to it. The complete frontend code is below.TypeScript /* file: app.component.ts */import { Component, ViewChild } from '@angular/core';import { HotTableModule, HotTableComponent } from '@handsontable/angular-wrapper';import type {DataProviderQueryParameters,RowsCreatePayload,RowUpdatePayload,} from 'handsontable/plugins/dataProvider';// Express reads sort as sort[column]/sort[order] and filters as flattened// array entries: filters[N][prop], filters[N][condition], filters[N][value][0].// Zod's safeParse coerces the string values and validates the shape.function buildUrl(params: DataProviderQueryParameters): string {const query = new URLSearchParams();query.set('page', String(params.page));query.set('pageSize', String(params.pageSize));if (params.sort) {query.set('sort[column]', params.sort.prop);query.set('sort[order]', params.sort.order);}if (params.filters?.length) {let idx = 0;params.filters.forEach(({ prop, conditions }) => {conditions.forEach((cond) => {query.set(`filters[${idx}][prop]`, prop);if (cond?.name) {query.set(`filters[${idx}][condition]`, cond.name);}cond?.args.forEach((v, j) => {query.set(`filters[${idx}][value][${j}]`, String(v));});idx++;});});}return `/tickets?${query.toString()}`;}@Component({standalone: true,imports: [HotTableModule],selector: 'example1-server-side-expressjs',template: `<div><hot-table [settings]="settings"></hot-table></div>`,})export class AppComponent {@ViewChild(HotTableComponent) readonly hotRef!: HotTableComponent;settings = {dataProvider: {rowId: 'id',// Called on every page change, sort, and filter.fetchRows: async (queryParameters: DataProviderQueryParameters, { signal }: { signal: AbortSignal }) => {const res = await fetch(buildUrl(queryParameters), { signal });if (!res.ok) throw new Error(`Server error ${res.status}`);return res.json();},// Fires when the user inserts rows via the context menu.// payload: { position: 'above'|'below', referenceRowId, rowsAmount }onRowsCreate: async ({ rowsAmount }: RowsCreatePayload) => {const rows = Array.from({ length: rowsAmount }, () => ({subject: '',status: 'open',priority: 'medium',assignee: '',createdAt: new Date().toISOString().slice(0, 10),}));const res = await fetch('/tickets', {method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify(rows),});if (!res.ok) throw new Error(`Create failed: ${res.status}`);return res.json();},// Fires after a cell edit, paste, or autofill batch.onRowsUpdate: async (rows: RowUpdatePayload[]) => {const payload = rows.map(({ id, changes }) => ({ id, ...changes }));const res = await fetch('/tickets', {method: 'PATCH',headers: { 'Content-Type': 'application/json' },body: JSON.stringify(payload),});if (!res.ok) throw new Error(`Update failed: ${res.status}`);},// Fires after the user confirms deletion.onRowsRemove: async (rowIds: unknown[]) => {const res = await fetch('/tickets', {method: 'DELETE',headers: { 'Content-Type': 'application/json' },body: JSON.stringify(rowIds),});if (!res.ok) throw new Error(`Delete failed: ${res.status}`);},},pagination: { pageSize: 5 },columnSorting: true,filters: true,dropdownMenu: true,contextMenu: true,emptyDataState: true,notification: true,colHeaders: ['ID', 'Subject', 'Status', 'Priority', 'Assignee', 'Created'],columns: [{ data: 'id', type: 'text', readOnly: true, width: 50 },{ data: 'subject', type: 'text', width: 280 },{data: 'status',type: 'dropdown',source: ['open', 'in-progress', 'resolved', 'closed'],width: 110,},{data: 'priority',type: 'dropdown',source: ['low', 'medium', 'high', 'critical'],width: 90,},{ data: 'assignee', type: 'text', width: 140 },{ data: 'createdAt', type: 'date', dateFormat: 'YYYY-MM-DD', width: 110 },],rowHeaders: true,height: 'auto',width: '100%',autoWrapRow: true,};}/* end-file *//* file: app.config.ts */import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';import { registerAllModules } from 'handsontable/registry';import { HOT_GLOBAL_CONFIG, HotGlobalConfig, NON_COMMERCIAL_LICENSE } from '@handsontable/angular-wrapper';registerAllModules();export const appConfig: ApplicationConfig = {providers: [provideZoneChangeDetection({ eventCoalescing: true }),{provide: HOT_GLOBAL_CONFIG,useValue: { license: NON_COMMERCIAL_LICENSE } as HotGlobalConfig,},],};/* end-file */HTML <div><example1-server-side-expressjs></example1-server-side-expressjs></div>What’s happening:
buildUrlhelperbuildUrlconverts Handsontable’sDataProviderQueryParametersobject into the bracket-notation query string that Express parses correctly. Key points:- Sort uses
sort[column]mapped fromparams.sort.prop— Zod’s schema coerces the string values to the correct types automatically. - Filters are flattened from
DataProviderFilterColumn[](each withpropandconditions: [{name, args}]) into one indexed entry per condition. This is the same bracket-notation format used by the NestJS recipe, which means you can swap the backend without changing the frontendbuildUrlfunction.
fetchRowsHandsontable calls
fetchRowswhenever the user changes the page, sorts a column, or applies a filter. TheAbortSignalfrom the second argument is passed tofetch(). When the user changes the page before the current request finishes, Handsontable aborts the in-flight request. Without the signal, a slow previous response can arrive after a faster one and overwrite the displayed data.onRowsCreateonRowsCreatereceives{ rowsAmount }— the number of rows the user wants to add. The frontend builds an array of default objects (one per row) and sends them toPOST /tickets. The server inserts each row, PostgreSQL assigns a UUID, and the created rows are returned. Handsontable uses the returnedidvalues to replace the temporary client-side IDs — without this the grid loses track of newly created rows.onRowsUpdatedataProviderpasses each updated row as{ id, changes }wherechangescontains only the modified columns. The frontend flattens this into{ id, ...changes }before sending to the API. The service finds each row byid, callsrepo.update(id, rest), and returns the updated entities.onRowsRemoveHandsontable passes an array of
idstrings matchingdataProvider.rowId. The router receives them asreq.body(astring[]) and passes them toservice.removeMany().notification: trueandemptyDataState: truenotification: trueenables the built-in error toast. WhenfetchRowsor a mutation callback throws, Handsontable shows a dismissible error message. Fetch failures also add a Refetch action that callsfetchRowsagain.emptyDataState: trueshows a placeholder message when the current filter combination returns zero rows, instead of leaving the grid blank.- Sort uses
How It Works — Complete Flow
- Initial load: Handsontable calls
fetchRowswith{ page: 1, pageSize: 5 }. - Frontend builds:
GET /tickets?page=1&pageSize=5. - Express routes the request to the
GET /ticketshandler; Zod coerces and validates the query params. - Service queries: runs SQL with
skip(0).take(5).getManyAndCount()and returns{ rows: [...5 tickets...], totalRows: 12 }. - User sorts by priority:
fetchRowscalled withsort: { column: 'priority', order: 'asc' }. - Frontend builds:
GET /tickets?page=1&pageSize=5&sort[column]=priority&sort[order]=asc. - Service sorts using
qb.orderBy('ticket.priority', 'ASC')and returns the first page of sorted results. - User filters status = open:
fetchRowscalled withfilters: [{ prop: 'status', condition: 'eq', value: ['open'] }]. - Frontend builds:
GET /tickets?...&filters[0][prop]=status&filters[0][condition]=eq&filters[0][value][0]=open. - Zod coerces: the bracket-notation params are parsed and validated; the
switchmapseqto a strict equality predicate. - User edits assignee:
onRowsUpdate([{ id: '3', assignee: 'Li Wei' }])sent viaPATCH /tickets. - Service updates:
repo.update(id, { assignee: 'Li Wei' })— only the changed column is written to the database.
What you learned
- How to use Zod’s
safeParsewithz.coercein an Express router to parse and validate Handsontable’s bracket-notation query parameters without decorators or a DI container. - How bracket-notation serialization maps Handsontable’s sort and filter objects to plain TypeScript interfaces —
sort[column]=statusbecomes{ sort: { column: 'status' } }after Zod coercion. - How to map Handsontable filter condition names (
eq,contains,begins_with, etc.) to TypeORM QueryBuilderWHEREclauses with case-insensitiveLOWER()and LIKE-safe escaping. - How PostgreSQL UUID generation (
gen_random_uuid()) handles ID assignment for batch creates without requiring an application-level counter. - Why
onRowsCreatemust return the created rows with server-assigned IDs. - How
notification: trueprovides automatic error toasts with a Refetch button for fetch failures. - How
emptyDataState: trueshows a placeholder when no rows match the active filters.
Next steps
- Swap PostgreSQL for a different database by updating the TypeORM
DataSourceconfig and the migration file. - Add authentication — pass a
Bearertoken in thefetchRowsfetch headers and protect mutation endpoints with an Express middleware that verifies the token. - Share the Zod schema types between the Express backend and the Handsontable frontend in a monorepo using a shared
packages/typesworkspace package. - Compare with the NestJS recipe to see the same pattern implemented with decorators, a DI container, and
class-validatorinstead of Zod. - Compare with the Symfony recipe to see the same Handsontable frontend wired to a PHP backend using the same endpoint shapes.