Server-side Data with NestJS
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 GitHubThis 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()andclass-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
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 setupThe project uses
class-validatorandclass-transformerfor request validation:What’s happening:
class-transformerconverts query-string values — which are always strings — into the TypeScript types declared in your DTO. For example,page=2in the query string becomes the number2.class-validatorthen validates those typed values against constraints such as@IsInt()and@Min(1), and rejects invalid requests with a400response 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.
Define the data model
Copy
ticket.entity.tsintosrc/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:
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.
Create the fetch DTO
Copy
fetch-tickets.dto.tsintosrc/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)onpageandpageSizeQuery-string values arrive as strings.
@Type(() => Number)tellsclass-transformerto coerce"2"to2before@IsInt()runs. Without this decorator,@IsInt()would reject every request because"2"is a string, not an integer.@ValidateNested()onsortandfilters@ValidateNested()applies the constraints declared on the nested DTO class. Without it,class-validatoronly checks that the outer object has asortfield — it does not inspectsort.columnorsort.order.@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]=openNestJS parses this as
filters = { '0': { prop: 'status', ... } }when only one filter is present and as an array when multiple filters are present. The@Transformdecorator normalizes both shapes into a consistentFilterConditionDto[].@IsArray()onFilterConditionDto.valueA filter condition can carry one or two values depending on the condition type (for example,
betweenuses two).@IsArray()accepts both a single-element and a two-element array from Handsontable.Bootstrap with CORS and
ValidationPipeCopy
main.tsintosrc/: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:5173calling the NestJS API onlocalhost: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' });ValidationPipeoptionsOption What it does transform: trueActivates class-transformerso@Type()decorators convert string values to numbers and nested objectsenableImplicitConversion: trueAlso converts primitive types without an explicit @Type()decorator — ensures deeply nested query params are coerced correctlywhitelist: trueStrips any properties not declared in the DTO, preventing extra data from reaching the service Inline
AppModuleThe recipe declares
AppModuleinline inmain.tsfor brevity. In a real application, moveTicketsControllerandTicketsServiceinto a dedicatedTicketsModule:@Module({ controllers: [TicketsController], providers: [TicketsService] })export class TicketsModule {}@Module({ imports: [TicketsModule] })export class AppModule {}Implement the service
Copy
tickets.service.tsintosrc/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, 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 DTO maps this to{ 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((dto.page - 1) * dto.pageSize).take(dto.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 callthis.repo.save()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 controller 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 controller
Copy
tickets.controller.tsintosrc/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/ticketsprefix.@Query() query: FetchTicketsDtobinds the parsed query string to the DTO. TheValidationPipeconfigured inmain.tstransforms and validates the values beforefindAll()receives them.create()acceptsCreateTicketDto | 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 rowidplus only the changed columns. The service finds each row by ID and applies the changes withObject.assign.removeMany()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 NestJS 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';// NestJS reads sort as sort[column]/sort[order] and filters as flattened// array entries: filters[N][prop], filters[N][condition], filters[N][value][0].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-nestjs',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,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-nestjs></example1-server-side-nestjs></div>What’s happening:
buildUrlhelperbuildUrlconverts Handsontable’sDataProviderQueryParametersobject into the bracket-notation query string that NestJS parses correctly. Key points:- Sort uses
sort[column]mapped fromparams.sort.prop— NestJS’s@Query()+class-transformerparses bracket notation directly into the typedSortDto. - Filters are flattened from
DataProviderFilterColumn[](each withpropandconditions: [{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 theFilterConditionDto[]via@Query().
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 controller deserializes them asstring[]and passes them toticketsService.removeMany().beforeRowsMutationbeforeRowsMutationis a synchronous hook that fires before any mutation is sent. Returningfalsecancels the operation. Because the hook is synchronous it cannot await a native browserconfirm()or a promise — instead it shows a non-blocking notification with Delete and Cancel actions. TheremoveConfirmedflag lets the second call (issued by the Delete action) bypass the guard and proceed.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.contextMenu: trueenables the right-click context menu with “Insert row above / below” and “Remove row” items.- Sort uses
How It Works — Complete Flow
- Initial load: Handsontable calls
fetchRowswith{ page: 1, pageSize: 5 }. - Frontend builds:
GET /tickets?page=1&pageSize=5. - NestJS routes the request to
TicketsController.findAllviaFetchTicketsDto. - 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 the store using
localeCompareand 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. - DTO deserializes:
class-transformermaps bracket notation toFilterConditionDto[]; theswitchmapseqto 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
class-validatorandclass-transformerin a NestJSValidationPipeto parse and validate Handsontable’s query parameters. - How bracket-notation serialization maps Handsontable’s sort and filter objects to NestJS
@Query()DTOs —sort[column]=statusbecomes{ sort: { column: 'status' } }. - 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 Flyway-equivalent migration file. - Add authentication — pass a
Bearertoken in thefetchRowsfetch headers and protect mutation endpoints with a NestJSAuthGuard. - Share the DTO types between the NestJS backend and the Handsontable frontend in a monorepo using a shared
packages/typesworkspace 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.