Skip to content

This tutorial shows how to wire Handsontable’s dataProvider plugin to a NestJS 10 backend. The backend provides paginated, sorted, and filtered server-side data with full CRUD operations using an in-memory store.

Overview

View full example on GitHub

This recipe shows how to connect Handsontable’s dataProvider plugin to a NestJS 10 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: Intermediate Time: ~40 minutes Stack: NestJS 10, TypeScript, TypeORM 0.3, PostgreSQL 16, class-validator, class-transformer, Handsontable dataProvider

What You’ll Build

A support-tickets data grid that:

  • Fetches paginated rows from a NestJS 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 in NestJS with @Query() and class-transformer
  • Seeds the database with 12 realistic support tickets via a migration

Before you begin

  • Docker and Docker Compose installed
  • Node.js 18 or later and npm installed
  • Basic familiarity with NestJS modules, controllers, and services
  1. Start the project

    Run the one-command bootstrap script, which starts PostgreSQL, runs TypeORM migrations, seeds 12 sample tickets, starts the NestJS backend, and opens the Vite dev server:

    Terminal window
    bash setup.sh
    # or: make setup

    The project uses class-validator and class-transformer for request validation:

    What’s happening:

    • class-transformer converts query-string values — which are always strings — into the TypeScript types declared in your DTO. For example, page=2 in the query string becomes the number 2.
    • class-validator then validates those typed values against constraints such as @IsInt() and @Min(1), and rejects invalid requests with a 400 response before they reach your service.

    Together these two libraries give you end-to-end type safety from the HTTP request all the way to the TypeScript service method.

  2. Define the data model

    Copy ticket.entity.ts into src/tickets/:

    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. Create the fetch DTO

    Copy fetch-tickets.dto.ts into src/tickets/dto/:

    TypeScript
    import { Transform, Type } from 'class-transformer';
    import { IsArray, IsInt, IsOptional, IsString, Min, ValidateNested } from 'class-validator';
    /**
    * A single filter condition sent by Handsontable's Filters plugin.
    *
    * Handsontable serializes filters as an array of objects, one per column:
    * [{ prop: 'status', condition: 'eq', value: ['open'] }]
    *
    * The query string is encoded as:
    * filters[0][prop]=status&filters[0][condition]=eq&filters[0][value][]=open
    */
    export class FilterConditionDto {
    @IsString()
    prop: string;
    /** Handsontable condition name, e.g. 'eq', 'contains', 'begins_with'. */
    @IsString()
    condition: string;
    /** One or two values depending on the condition type. */
    @IsArray()
    @IsString({ each: true })
    value: string[];
    }
    /**
    * A sort descriptor sent by Handsontable's ColumnSorting plugin.
    *
    * Handsontable serializes the active sort as a nested object:
    * sort[column]=status&sort[order]=asc
    */
    export class SortDto {
    /** The data property name of the sorted column. */
    @IsString()
    column: string;
    @IsString()
    order: 'asc' | 'desc';
    }
    /**
    * Query parameters for GET /tickets.
    *
    * NestJS uses class-transformer to convert query-string values (all strings)
    * to their TypeScript types before validation with class-validator runs.
    */
    export class FetchTicketsDto {
    /** 1-based page number (Handsontable sends page starting from 1). */
    @IsInt()
    @Min(1)
    @Type(() => Number)
    page: number = 1;
    @IsInt()
    @Min(1)
    @Type(() => Number)
    pageSize: number = 10;
    /** Optional sort descriptor; absent when no sort is applied. */
    @IsOptional()
    @ValidateNested()
    @Type(() => SortDto)
    sort?: SortDto;
    /**
    * Optional list of filter conditions.
    *
    * NestJS parses repeated bracket-style query params into arrays automatically
    * when ValidationPipe has transformOptions.enableImplicitConversion enabled.
    */
    @IsOptional()
    @ValidateNested({ each: true })
    @Type(() => FilterConditionDto)
    @Transform(({ value }) => (Array.isArray(value) ? value : value ? [value] : []))
    filters?: FilterConditionDto[];
    }

    What’s happening:

    @Type(() => Number) on page and pageSize

    Query-string values arrive as strings. @Type(() => Number) tells class-transformer to coerce "2" to 2 before @IsInt() runs. Without this decorator, @IsInt() would reject every request because "2" is a string, not an integer.

    @ValidateNested() on sort and filters

    @ValidateNested() applies the constraints declared on the nested DTO class. Without it, class-validator only checks that the outer object has a sort field — it does not inspect sort.column or sort.order.

    @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

    NestJS parses this as filters = { '0': { prop: 'status', ... } } when only one filter is present and as an array when multiple filters are present. The @Transform decorator normalizes both shapes into a consistent FilterConditionDto[].

    @IsArray() on FilterConditionDto.value

    A filter condition can carry one or two values depending on the condition type (for example, between uses two). @IsArray() accepts both a single-element and a two-element array from Handsontable.

  4. Bootstrap with CORS and ValidationPipe

    Copy main.ts into src/:

    TypeScript
    import 'reflect-metadata';
    import { ValidationPipe } from '@nestjs/common';
    import { NestFactory } from '@nestjs/core';
    import { Module } from '@nestjs/common';
    import { TypeOrmModule } from '@nestjs/typeorm';
    import { TicketsController } from './tickets.controller';
    import { TicketsService } from './tickets.service';
    import { TicketEntity } from './ticket.entity';
    /**
    * AppModule wires the TypeORM connection and the tickets feature together.
    *
    * TypeOrmModule.forRoot() creates the database connection.
    * TypeOrmModule.forFeature([TicketEntity]) registers the repository so
    * TicketsService can inject it via @InjectRepository(TicketEntity).
    */
    @Module({
    imports: [
    TypeOrmModule.forRoot({
    type: 'postgres',
    host: process.env.DB_HOST || 'localhost',
    port: parseInt(process.env.DB_PORT || '5432'),
    username: process.env.DB_USER || 'tickets',
    password: process.env.DB_PASS || 'tickets',
    database: process.env.DB_NAME || 'tickets',
    entities: [TicketEntity],
    synchronize: false,
    }),
    TypeOrmModule.forFeature([TicketEntity]),
    ],
    controllers: [TicketsController],
    providers: [TicketsService],
    })
    class AppModule {}
    async function bootstrap() {
    const app = await NestFactory.create(AppModule);
    /**
    * Enable CORS so the browser can call the API from a different origin.
    *
    * In production, replace the wildcard with your frontend domain:
    * app.enableCors({ origin: 'https://your-app.example.com' });
    */
    app.enableCors();
    /**
    * GlobalValidationPipe does two things for every incoming request:
    *
    * 1. transform: true -- converts query-string values (always strings) to
    * the types declared in the DTO (e.g. page: '2' -> page: 2).
    * Powered by class-transformer under the hood.
    *
    * 2. whitelist: true -- strips any properties that are not declared in the
    * DTO class, preventing unexpected data from reaching the service.
    */
    app.useGlobalPipes(
    new ValidationPipe({
    transform: true,
    transformOptions: { enableImplicitConversion: true },
    whitelist: true,
    }),
    );
    await app.listen(3000);
    console.log('NestJS server running on http://localhost:3000');
    }
    bootstrap();

    What’s happening:

    app.enableCors()

    Without CORS headers, the browser blocks requests from a different origin — for example, a Vite dev server on localhost:5173 calling the NestJS API on localhost:3000. enableCors() with no arguments allows all origins, which is safe for local development. In production, pass your deployed frontend domain:

    app.enableCors({ origin: 'https://your-app.example.com' });

    ValidationPipe options

    OptionWhat it does
    transform: trueActivates class-transformer so @Type() decorators convert string values to numbers and nested objects
    enableImplicitConversion: trueAlso converts primitive types without an explicit @Type() decorator — ensures deeply nested query params are coerced correctly
    whitelist: trueStrips any properties not declared in the DTO, preventing extra data from reaching the service

    Inline AppModule

    The recipe declares AppModule inline in main.ts for brevity. In a real application, move TicketsController and TicketsService into a dedicated TicketsModule:

    @Module({ controllers: [TicketsController], providers: [TicketsService] })
    export class TicketsModule {}
    @Module({ imports: [TicketsModule] })
    export class AppModule {}
  5. Implement the service

    Copy tickets.service.ts into src/tickets/:

    TypeScript
    import { Injectable } from '@nestjs/common';
    import { InjectRepository } from '@nestjs/typeorm';
    import { Repository } from 'typeorm';
    import { FetchTicketsDto } from './fetch-tickets.dto';
    import { TicketEntity, TicketPriority, TicketStatus } from './ticket.entity';
    export interface CreateTicketDto {
    subject: string;
    status: string;
    priority: string;
    assignee: string;
    createdAt: string;
    }
    export interface UpdateTicketDto extends Partial<CreateTicketDto> {
    id: string;
    }
    /**
    * TicketsService encapsulates all data-access logic via TypeORM.
    *
    * The Repository<TicketEntity> is injected by NestJS when AppModule
    * imports TypeOrmModule.forFeature([TicketEntity]).
    */
    @Injectable()
    export class TicketsService {
    constructor(
    @InjectRepository(TicketEntity)
    private readonly repo: Repository<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(dto: FetchTicketsDto): Promise<{ rows: TicketEntity[]; totalRows: number }> {
    const qb = this.repo.createQueryBuilder('ticket');
    // -- Filtering --
    // Each FetchTicketsDto filter carries a single condition object.
    // The frontend flattens multi-condition column filters before sending, so
    // one entry here maps to one WHERE clause in the query.
    if (dto.filters && dto.filters.length > 0) {
    for (const [i, filter] of dto.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;
    }
    }
    }
    // -- Sorting --
    // Handsontable sends { column: 'status', order: 'asc' } for the active sort.
    if (dto.sort) {
    qb.orderBy(`ticket.${dto.sort.column}`, dto.sort.order.toUpperCase() as 'ASC' | 'DESC');
    } else {
    qb.orderBy('ticket.createdAt', 'ASC');
    }
    // -- Pagination --
    const [rows, totalRows] = await qb
    .skip((dto.page - 1) * dto.pageSize)
    .take(dto.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 DTO maps this to { 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((dto.page - 1) * dto.pageSize)
    .take(dto.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 this.repo.save() 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 controller 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 controller

    Copy tickets.controller.ts into src/tickets/:

    TypeScript
    import {
    Body,
    Controller,
    Delete,
    Get,
    HttpCode,
    Post,
    Patch,
    Query,
    } from '@nestjs/common';
    import { FetchTicketsDto } from './fetch-tickets.dto';
    import { CreateTicketDto, TicketsService, UpdateTicketDto } from './tickets.service';
    /**
    * TicketsController maps HTTP verbs to TicketsService methods.
    *
    * Endpoint summary:
    *
    * GET /tickets -- paginated + sorted + filtered list
    * POST /tickets -- create one or more tickets (onRowsCreate payload shape)
    * PATCH /tickets -- batch update (onRowsUpdate payload shape)
    * DELETE /tickets -- batch delete (array of row IDs)
    */
    @Controller('tickets')
    export class TicketsController {
    constructor(private readonly ticketsService: TicketsService) {}
    /**
    * GET /tickets?page=1&pageSize=10&sort[column]=status&sort[order]=asc
    * &filters[0][prop]=status&filters[0][condition]=eq&filters[0][value][0]=open
    *
    * @Query() binds the parsed query string to FetchTicketsDto.
    * NestJS ValidationPipe (configured in main.ts) transforms string values to
    * their declared types (number, nested object) before the handler runs.
    */
    @Get()
    async findAll(@Query() query: FetchTicketsDto) {
    return this.ticketsService.findAll(query);
    }
    /**
    * POST /tickets
    *
    * Handsontable's onRowsCreate callback receives { rowsAmount } and the
    * frontend constructs default row objects for each new row. Each object
    * contains the column data keyed by the `data` property names configured
    * in Handsontable's columns option.
    *
    * Example body: [{ subject: '', status: 'open', priority: 'medium', ... }]
    *
    * The server persists the rows and returns them with database-generated
    * UUIDs so the grid can update its row map.
    */
    @Post()
    @HttpCode(201)
    async create(@Body() body: CreateTicketDto | CreateTicketDto[]) {
    const rows = Array.isArray(body) ? body : [body];
    return Promise.all(rows.map((dto) => this.ticketsService.create(dto)));
    }
    /**
    * PATCH /tickets
    *
    * Handsontable's onRowsUpdate callback receives an array of changed row
    * objects. The frontend flattens each { id, changes } into { id, ...changes }
    * before sending.
    *
    * Example body: [{ id: 'uuid-3', status: 'resolved' }]
    */
    @Patch()
    async updateMany(@Body() body: UpdateTicketDto[]) {
    return this.ticketsService.updateMany(body);
    }
    /**
    * DELETE /tickets
    *
    * Handsontable's onRowsRemove callback receives an array of row ID strings.
    *
    * Example body: ['uuid-3', 'uuid-7']
    */
    @Delete()
    @HttpCode(204)
    async removeMany(@Body() ids: string[]) {
    await this.ticketsService.removeMany(ids);
    }
    }

    What’s happening:

    • @Controller('tickets') sets the base path. All four endpoints share the /tickets prefix.
    • @Query() query: FetchTicketsDto binds the parsed query string to the DTO. The ValidationPipe configured in main.ts transforms and validates the values before findAll() receives them.
    • create() accepts CreateTicketDto | CreateTicketDto[] because Handsontable may send one new row or several. Wrapping a single object in an array normalizes the input before the service loop.
    • updateMany() receives an array of partial row objects — each includes the row id plus only the changed columns. The service finds each row by ID and applies the changes with Object.assign.
    • removeMany() 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 NestJS API runs on http://localhost:3000; Vite proxies all /tickets requests to it. The complete frontend code is below.

    JavaScript
    import Handsontable from 'handsontable/base';
    import { registerAllModules } from 'handsontable/registry';
    registerAllModules();
    // ---------------------------------------------------------------------------
    // URL builder
    // ---------------------------------------------------------------------------
    /**
    * Converts Handsontable's DataProviderQueryParameters into a URL query string
    * that the NestJS backend can parse with @Query() and class-transformer.
    *
    * Handsontable passes `params` to fetchRows every time the page, sort, or
    * filters change. The shape is:
    *
    * {
    * page: 1,
    * pageSize: 10,
    * sort: { prop: 'status', order: 'asc' } | undefined,
    * filters: [
    * { prop: 'status', operation: 'conjunction',
    * conditions: [{ name: 'eq', args: ['open'] }] }
    * ] | undefined,
    * }
    *
    * NestJS expects nested objects as bracket notation in the query string:
    * sort[column]=status&sort[order]=asc
    * filters[0][prop]=status&filters[0][condition]=eq&filters[0][value][0]=open
    *
    * Each DataProviderFilterColumn can have multiple conditions (e.g. between),
    * so we flatten them: one entry per condition, incrementing the index.
    */
    function buildUrl(params) {
    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 && params.filters.length > 0) {
    let idx = 0;
    params.filters.forEach((filter) => {
    filter.conditions.forEach((cond) => {
    query.set(`filters[${idx}][prop]`, filter.prop);
    query.set(`filters[${idx}][condition]`, cond.name);
    cond.args.forEach((arg, j) => {
    query.set(`filters[${idx}][value][${j}]`, String(arg));
    });
    idx++;
    });
    });
    }
    return `/tickets?${query.toString()}`;
    }
    // ---------------------------------------------------------------------------
    // Handsontable configuration
    // ---------------------------------------------------------------------------
    const container = document.querySelector('#example1');
    const statusLabel = document.querySelector('#status-label');
    const hotOptions = {
    /**
    * dataProvider wires Handsontable to a remote data source.
    *
    * rowId tells the grid which field uniquely identifies each row. This is
    * required for CRUD operations so the grid can track which rows were added,
    * changed, or removed.
    */
    dataProvider: {
    rowId: 'id',
    /**
    * fetchRows is called by the grid whenever the page, sort, or filter
    * changes. The `params` object contains the current pagination state,
    * active sort, and active filter conditions.
    *
    * The `signal` from the second argument is an AbortSignal -- pass it to
    * fetch() so in-flight requests are cancelled when the user navigates away
    * or triggers a new fetch before the previous one finishes.
    *
    * Return { rows, totalRows } so the grid can calculate the total number
    * of pages.
    */
    fetchRows: async (params, { signal }) => {
    const res = await fetch(buildUrl(params), { signal });
    if (!res.ok) {
    throw new Error(`Server error ${res.status}`);
    }
    return res.json();
    },
    /**
    * onRowsCreate receives { rowsAmount } -- the number of rows to add.
    * Build default row objects for each new row, POST them to the server,
    * and return the created rows with their server-generated UUIDs so the
    * grid can update its row map.
    */
    onRowsCreate: async ({ rowsAmount }) => {
    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();
    },
    /**
    * onRowsUpdate receives an array of { id, changes } objects.
    * Flatten each entry into { id, ...changes } before sending so the
    * server receives a plain object with only the changed fields.
    */
    onRowsUpdate: async (rows) => {
    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}`);
    }
    },
    /**
    * onRowsRemove is called after the user deletes rows.
    * `rowIds` is an array of the string IDs of the deleted rows.
    */
    onRowsRemove: async (rowIds) => {
    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 enables server-side paging. The grid sends the page number
    * and page size as part of the fetchRows params object.
    */
    pagination: { pageSize: 5 },
    /** columnSorting sends a sort descriptor in the fetchRows params. */
    columnSorting: true,
    /** filters sends an array of filter conditions in the fetchRows params. */
    filters: true,
    dropdownMenu: true,
    /**
    * emptyDataState shows a loading overlay while fetchRows is in flight
    * and an empty-state message when the server returns zero rows.
    */
    emptyDataState: true,
    /**
    * notification: true enables automatic error toasts when fetchRows,
    * onRowsCreate, onRowsUpdate, or onRowsRemove throw or reject. Fetch
    * failures also display a Refetch button in the toast.
    */
    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,
    licenseKey: 'non-commercial-and-evaluation',
    /**
    * afterDataProviderFetch fires after every successful fetchRows response.
    * Use it to update UI outside the grid (e.g., a status label or record count).
    */
    afterDataProviderFetch(result) {
    if (statusLabel) {
    statusLabel.textContent = `${result.totalRows} tickets total`;
    }
    },
    };
    // eslint-disable-next-line no-unused-vars
    const hot = new Handsontable(container, hotOptions);

    What’s happening:

    buildUrl helper

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

    • Sort uses sort[column] mapped from params.sort.prop — NestJS’s @Query() + class-transformer parses bracket notation directly into the typed SortDto.
    • Filters are flattened from DataProviderFilterColumn[] (each with prop and conditions: [{name, args}]) into one indexed entry per condition. This is the opposite of the Django/Laravel approach, which sends a JSON string — NestJS bracket notation maps directly to the FilterConditionDto[] via @Query().

    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 controller deserializes them as string[] and passes them to ticketsService.removeMany().

    beforeRowsMutation

    beforeRowsMutation is a synchronous hook that fires before any mutation is sent. Returning false cancels the operation. Because the hook is synchronous it cannot await a native browser confirm() or a promise — instead it shows a non-blocking notification with Delete and Cancel actions. The removeConfirmed flag lets the second call (issued by the Delete action) bypass the guard and proceed.

    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.

    contextMenu: true enables the right-click context menu with “Insert row above / below” and “Remove row” items.

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. NestJS routes the request to TicketsController.findAll via FetchTicketsDto.
  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 the store using localeCompare 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. DTO deserializes: class-transformer maps bracket notation to FilterConditionDto[]; 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 class-validator and class-transformer in a NestJS ValidationPipe to parse and validate Handsontable’s query parameters.
  • How bracket-notation serialization maps Handsontable’s sort and filter objects to NestJS @Query() DTOs — sort[column]=status becomes { sort: { column: 'status' } }.
  • 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 Flyway-equivalent migration file.
  • Add authentication — pass a Bearer token in the fetchRows fetch headers and protect mutation endpoints with a NestJS AuthGuard.
  • Share the DTO types between the NestJS backend and the Handsontable frontend in a monorepo using a shared packages/types workspace package.
  • Compare with the Spring Boot recipe to see the same Handsontable frontend wired to a Java backend using the same endpoint shapes.
  • Compare with the Symfony recipe to see the same Handsontable frontend wired to a PHP backend using the same endpoint shapes.