Skip to content

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 GitHub

This 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 safeParse using z.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
  1. 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 setup

    The 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=2 in the query string becomes the number 2.
    • Zod’s safeParse validates 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 a 400 before the request reaches the service.
    • The z.union([z.array(...), ...]).transform(...) on filters normalises 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.

  2. Define the data model

    Copy ticket.entity.ts into src/:

    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:

    • TicketStatus and TicketPriority are union types that match the source arrays in the Handsontable column definitions. Sharing these types between server and client prevents mismatched values.
    • TicketEntity is a TypeORM entity backed by a PostgreSQL table. The @Entity('tickets') decorator maps the class to the tickets table. Twelve seed rows are inserted by a migration so pagination and filtering are meaningful from the first load.
    • The id field is a UUID string generated by PostgreSQL (gen_random_uuid()). Handsontable’s dataProvider.rowId option identifies rows by string, so a UUID id requires no conversion.
  3. Define validation types with Zod

    Copy types.ts into src/:

    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() on page and pageSize

    Query-string values arrive as strings. z.coerce.number() converts "2" to 2 before 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(...) on filters

    Handsontable 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]=open

    Express parses this as filters = { '0': { prop: 'status', ... } } when only one filter is present and as an array when multiple filters are present. The z.union with .transform normalizes both shapes into a consistent FilterConditionDto[].

    ALLOWED_COLUMNS and ALLOWED_CONDITIONS enums

    Using z.enum(ALLOWED_COLUMNS) on the prop field ensures users cannot inject arbitrary column names into the SQL query builder. Any request with an unrecognised column name is rejected with a 400 before reaching the service.

  4. Bootstrap the server

    Copy main.ts into src/:

    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:5173 calling the Express API on localhost: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/json request bodies and exposes the result as req.body. Without it, req.body is undefined and 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. Starting app.listen() inside the .then() callback ensures the server only accepts requests after the database is ready.

  5. Implement the service

    Copy tickets.service.ts into src/:

    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, and not_empty. The switch statement maps each name to a TypeORM QueryBuilder WHERE clause:

    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 with AND using repeated andWhere() calls.

    Sorting

    Handsontable sends sort[column]=status&sort[order]=asc as query params. The Zod schema coerces this into { column: 'status', order: 'asc' }. The service calls qb.orderBy() with the column name and uppercased direction. Without a sort param, rows fall back to createdAt ASC.

    Pagination

    const [rows, totalRows] = await qb
    .skip((params.page - 1) * params.pageSize)
    .take(params.pageSize)
    .getManyAndCount();

    Handsontable sends a 1-based page index. Subtracting 1 converts it to a 0-based SQL offset. getManyAndCount() runs the data query and a COUNT(*) in one round trip. totalRows is 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 calls this.repo.save(ticket), which runs the INSERT and returns the entity with its database-assigned id. Batch creates call service.create() once per row inside Promise.all(), so each row receives a unique UUID regardless of timing.

    create must return the created row

    After inserting a row the service returns it with its server-assigned id. The router passes this return value back to Handsontable via onRowsCreate. 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.

  6. Add the router

    Copy tickets.router.ts into src/:

    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 with 400 and the Zod error details — the service never runs.
    • The GET handler passes parsed.data directly to service.findAll(). Because Zod’s safeParse returns a typed ParsedData object, the service receives correctly typed page, pageSize, sort, and filters values.
    • The POST handler wraps a single object in an array to normalise the input: Handsontable may send one new row or several.
    • The PATCH handler receives an array of partial row objects — each includes the row id plus only the changed columns.
    • The DELETE handler receives an array of ID strings and returns 204 No Content. Handsontable only checks for a non-error HTTP status on delete responses.

    Endpoint summary:

    HTTP methodPathHandsontable callback
    GET/ticketsfetchRows
    POST/ticketsonRowsCreate
    PATCH/ticketsonRowsUpdate
    DELETE/ticketsonRowsRemove
  7. Wire up Handsontable

    Start the backend and the Vite dev server with bash setup.sh (or make setup), then open http://localhost:5173. The Express API runs on http://localhost:3000; Vite proxies all /tickets requests 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:

    buildUrl helper

    buildUrl converts Handsontable’s DataProviderQueryParameters object into the bracket-notation query string that Express parses correctly. Key points:

    • Sort uses sort[column] mapped from params.sort.prop — Zod’s schema coerces the string values to the correct types automatically.
    • Filters are flattened from DataProviderFilterColumn[] (each with prop and conditions: [{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 frontend buildUrl function.

    fetchRows

    Handsontable calls fetchRows whenever the user changes the page, sorts a column, or applies a filter. The AbortSignal from the second argument is passed to fetch(). 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.

    onRowsCreate

    onRowsCreate receives { rowsAmount } — the number of rows the user wants to add. The frontend builds an array of default objects (one per row) and sends them to POST /tickets. The server inserts each row, PostgreSQL assigns a UUID, and the created rows are returned. Handsontable uses the returned id values to replace the temporary client-side IDs — without this the grid loses track of newly created rows.

    onRowsUpdate

    dataProvider passes each updated row as { id, changes } where changes contains only the modified columns. The frontend flattens this into { id, ...changes } before sending to the API. The service finds each row by id, calls repo.update(id, rest), and returns the updated entities.

    onRowsRemove

    Handsontable passes an array of id strings matching dataProvider.rowId. The router receives them as req.body (a string[]) and passes them to service.removeMany().

    notification: true and emptyDataState: true

    notification: true enables the built-in error toast. When fetchRows or a mutation callback throws, Handsontable shows a dismissible error message. Fetch failures also add a Refetch action that calls fetchRows again.

    emptyDataState: true shows a placeholder message when the current filter combination returns zero rows, instead of leaving the grid blank.

How It Works — Complete Flow

  1. Initial load: Handsontable calls fetchRows with { page: 1, pageSize: 5 }.
  2. Frontend builds: GET /tickets?page=1&pageSize=5.
  3. Express routes the request to the GET /tickets handler; Zod coerces and validates the query params.
  4. Service queries: runs SQL with skip(0).take(5).getManyAndCount() and returns { rows: [...5 tickets...], totalRows: 12 }.
  5. User sorts by priority: fetchRows called with sort: { column: 'priority', order: 'asc' }.
  6. Frontend builds: GET /tickets?page=1&pageSize=5&sort[column]=priority&sort[order]=asc.
  7. Service sorts using qb.orderBy('ticket.priority', 'ASC') and returns the first page of sorted results.
  8. User filters status = open: fetchRows called with filters: [{ prop: 'status', condition: 'eq', value: ['open'] }].
  9. Frontend builds: GET /tickets?...&filters[0][prop]=status&filters[0][condition]=eq&filters[0][value][0]=open.
  10. Zod coerces: the bracket-notation params are parsed and validated; the switch maps eq to a strict equality predicate.
  11. User edits assignee: onRowsUpdate([{ id: '3', assignee: 'Li Wei' }]) sent via PATCH /tickets.
  12. 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 safeParse with z.coerce in 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]=status becomes { sort: { column: 'status' } } after Zod coercion.
  • How to map Handsontable filter condition names (eq, contains, begins_with, etc.) to TypeORM QueryBuilder WHERE clauses with case-insensitive LOWER() and LIKE-safe escaping.
  • How PostgreSQL UUID generation (gen_random_uuid()) handles ID assignment for batch creates without requiring an application-level counter.
  • Why onRowsCreate must return the created rows with server-assigned IDs.
  • How notification: true provides automatic error toasts with a Refetch button for fetch failures.
  • How emptyDataState: true shows a placeholder when no rows match the active filters.

Next steps

  • Swap PostgreSQL for a different database by updating the TypeORM DataSource config and the migration file.
  • Add authentication — pass a Bearer token in the fetchRows fetch 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/types workspace package.
  • Compare with the NestJS recipe to see the same pattern implemented with decorators, a DI container, and class-validator instead of Zod.
  • Compare with the Symfony recipe to see the same Handsontable frontend wired to a PHP backend using the same endpoint shapes.