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 an in-memory store.
Difficulty: Intermediate
Time: ~40 minutes
Stack: NestJS 10, TypeScript, 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 an in-memory array predicate — 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 store with 12 realistic support tickets on startup
Before you begin
- Node.js 18 or later installed
- NestJS CLI installed:
npm install -g @nestjs/cli - Basic familiarity with NestJS modules, controllers, and services
- A Handsontable project with the
dataProviderplugin available
Scaffold the NestJS project
Create a new NestJS application and install the validation libraries:
Terminal window nest new tickets-api --package-manager npmcd tickets-apinpm install class-validator class-transformerWhat’s happening:
nest newscaffolds a complete NestJS project withAppModule,AppController, andAppService. You will replace the default controller and service with aTicketsControllerandTicketsService.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/:What’s happening:
TicketStatusandTicketPriorityare union types that match thesourcearrays in the Handsontable column definitions. Sharing these types between server and client prevents mismatched values.ticketsStoreis an in-memory array that acts as the database for this recipe. Twelve realistic support-ticket rows make pagination and filtering meaningful from the first load.- The
idfield is a string rather than a number because Handsontable’sdataProvider.rowIdoption tracks rows by string identity. Converting numbers to strings at the database boundary keeps the rest of the code consistent.
Switching to TypeORM: Replace the interface and array with a
@Entity()class and injectRepository<TicketEntity>into the service. ThefindAndCount(),save(), anddelete()calls map directly to the array operations in this recipe.Create the fetch DTO
Copy
fetch-tickets.dto.tsintosrc/tickets/dto/: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/: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/: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 JavaScript string predicate. With TypeORM, you would map the same names toWHEREclauses instead:case 'contains':queryBuilder.andWhere(`ticket.${filter.prop} LIKE :val`, { val: `%${filter.value[0]}%` });break;Sorting
Handsontable sends
{ column: 'status', order: 'asc' }for the active sort. The service readscolumnandorderfrom the DTO and callslocaleComparefor consistent alphabetical ordering. The result is negated for'desc'.Pagination
const start = (dto.page - 1) * dto.pageSize;return { rows: tickets.slice(start, start + dto.pageSize), totalRows };Handsontable sends a 1-based
pageindex. Subtracting 1 converts it to a 0-based offset forArray.slice().totalRowsis the count of matching rows before slicing — Handsontable uses this number to render the correct number of pages in the pagination bar.ID generation with an incrementing counter
let nextId = ticketsStore.length + 1;Using an incrementing counter rather than
Date.now()avoids duplicate IDs whenonRowsCreatesends a batch of rows. All calls in the batch happen within the same millisecond, soDate.now()would return the same value for every row in the batch.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/: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
With the server running on
http://localhost:3000, configure Handsontable to use thedataProviderplugin. The complete frontend code is below.@code
What’s happening:
buildUrlhelperfunction buildUrl(base, 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.column);query.set('sort[order]', params.sort.order);}if (params.filters?.length) {params.filters.forEach((filter, i) => {query.set(`filters[${i}][prop]`, filter.prop);query.set(`filters[${i}][condition]`, filter.condition);filter.value.forEach((v, j) => query.set(`filters[${i}][value][${j}]`, String(v)));});}return `${base}?${query.toString()}`;}buildUrlconverts Handsontable’sDataProviderQueryParametersobject into the bracket-notation query string that NestJS parses correctly. The key difference from the Laravel recipe is the notation style: NestJS expectssort[column]=statuswhile Laravel accepts a flat JSON string. The bracket notation maps directly to the nestedSortDtoobject via@Query()andclass-transformer.fetchRowsfetchRows: async (params, { signal }) => {const url = buildUrl('http://localhost:3000/tickets', params);const res = await fetch(url, { signal });if (!res.ok) throw new Error(`Server error ${res.status}`);return res.json();},Handsontable 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.onRowsCreateonRowsCreate: async (payload) => {const res = await fetch('http://localhost:3000/tickets', {method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify(payload),});return res.json(); // Must return created rows with server-assigned IDs.},Handsontable passes a
payloadobject with the new row data keyed by the columndataproperties. The server creates the row, assigns a stringid, and returns the created row. Handsontable uses the returnedidto replace the temporary client-side ID — without this the grid loses track of the row.onRowsUpdateonRowsUpdate: async (rows) => {await fetch('http://localhost:3000/tickets', {method: 'PATCH',headers: { 'Content-Type': 'application/json' },body: JSON.stringify(rows),});},Handsontable batches all cell edits from a single user action into one array. Each element is a partial row object that includes the row
idand only the columns the user changed. The service applies the changes selectively usingObject.assign.onRowsRemoveonRowsRemove: async (rowIds) => {await fetch('http://localhost:3000/tickets', {method: 'DELETE',headers: { 'Content-Type': 'application/json' },body: JSON.stringify(rowIds),});},Handsontable passes an array of
idstrings matchingdataProvider.rowId. The controller deserializes them asstring[]and passes them toticketsService.removeMany().notification: trueandemptyDataState: truenotification: true,emptyDataState: true,notification: 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.
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: slices the in-memory store to rows 0—4 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:
Object.assign(ticketsStore[idx], { assignee: 'Li Wei' })— only the changed column is written.
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 array predicates or TypeORMWHEREclauses. - How to use an incrementing counter for ID generation to avoid duplicate IDs in batch creates.
- 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
- Replace the in-memory store with TypeORM + SQLite (zero extra config) or PostgreSQL.
- 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.