Skip to content

This tutorial shows how to wire Handsontable’s dataProvider plugin to a Django REST Framework (DRF) backend. The backend handles pagination, sorting, and filtering on the server. The frontend displays results and sends all edits back to the API.

View full example on GitHub

Difficulty: Intermediate
Time: ~30 minutes
Backend: Python 3.11+, Django 4+, Django REST Framework 3.14+

What you’ll build

An employee directory grid that:

  • Loads data page by page from a DRF API
  • Sorts rows by any column on the server
  • Filters rows by column value on the server
  • Creates, updates, and deletes rows via batch API endpoints
  • Handles Django’s CSRF protection transparently

Before you begin

  • Docker and Docker Compose installed
  • Node.js and npm installed

No local Python installation is required — the Django backend and PostgreSQL database run inside Docker.

Step 1 — Set up the Django app

Start the backend and the Vite dev server with bash setup.sh (or make setup). The script builds and starts PostgreSQL + Django via Docker Compose, runs migrations, seeds 50 employee records, and opens the Vite dev server at http://localhost:5173.

The Django project uses a employees app:

Why a separate app?
Django apps are self-contained modules. Keeping the employee model, serializer, and views in one app makes the code easier to extend and test independently.

Register the app and required packages in settings.py:

Python
# settings.py -- relevant additions only.
# Add these to your existing Django settings file.
INSTALLED_APPS = [
# ... your existing apps ...
"rest_framework",
"corsheaders", # django-cors-headers
"django_filters", # django-filter (optional, for DjangoFilterBackend)
"employees", # your app
]
MIDDLEWARE = [
# CorsMiddleware must come before CommonMiddleware.
"corsheaders.middleware.CorsMiddleware",
"django.middleware.common.CommonMiddleware",
# ... rest of your middleware ...
]
# --- CORS ---
# Allow requests from the frontend dev server.
# In production, restrict this to your actual domain(s).
CORS_ALLOWED_ORIGINS = [
"http://localhost:5173", # Vite dev server
"http://localhost:3000", # Create React App / Next.js dev server
"https://your-production-domain.com",
]
# If your frontend sends credentials (cookies, Authorization headers), also set:
# CORS_ALLOW_CREDENTIALS = True
# --- Django REST Framework ---
REST_FRAMEWORK = {
"DEFAULT_PAGINATION_CLASS": "employees.pagination.EmployeePagination",
"PAGE_SIZE": 10,
"DEFAULT_FILTER_BACKENDS": [
"rest_framework.filters.OrderingFilter",
"rest_framework.filters.SearchFilter",
# Uncomment to add DjangoFilterBackend for exact field matching:
# "django_filters.rest_framework.DjangoFilterBackend",
],
}

Step 2 — Define the Employee model

Create the model in employees/models.py:

Python
from django.db import models
class Employee(models.Model):
"""
Employee model for the HR directory demo.
Fields match the column definitions used in the Handsontable frontend:
id is generated automatically by Django and used as rowId.
"""
DEPARTMENT_CHOICES = [
("Engineering", "Engineering"),
("Marketing", "Marketing"),
("Sales", "Sales"),
("HR", "HR"),
("Finance", "Finance"),
("Operations", "Operations"),
]
first_name = models.CharField(max_length=100)
last_name = models.CharField(max_length=100)
department = models.CharField(max_length=100, choices=DEPARTMENT_CHOICES)
role = models.CharField(max_length=100)
# DecimalField avoids floating-point rounding for currency values.
salary = models.DecimalField(max_digits=10, decimal_places=2)
class Meta:
# Default ordering by last name keeps the initial grid display predictable.
ordering = ["last_name", "first_name"]
def __str__(self):
return f"{self.first_name} {self.last_name} ({self.department})"

What’s happening:

  • DecimalField stores salary without floating-point rounding errors — important for currency values.
  • ordering in Meta sets the default query order. The OrderingFilter overrides it when the user sorts a column.
  • Django automatically adds an id primary key. This becomes the rowId value on the frontend.

Run migrations to create the database table:

Terminal window
python manage.py makemigrations employees
python manage.py migrate

Step 3 — Seed the database

Create the seed command file at employees/management/commands/seed.py:

Python
# employees/management/commands/seed.py
# Run with: python manage.py seed
# Place this file at:
# employees/management/__init__.py (empty)
# employees/management/commands/__init__.py (empty)
# employees/management/commands/seed.py (this file)
from django.core.management.base import BaseCommand
from employees.models import Employee
SEED_DATA = [
("Ana", "García", "Engineering", "Senior Engineer", 95000),
("James", "Okafor", "Engineering", "Backend Developer", 87000),
("Li", "Wei", "Marketing", "Campaign Manager", 72000),
("Sara", "Müller", "HR", "HR Business Partner", 68000),
("Carlos", "Rivera", "Sales", "Account Executive", 76000),
("Priya", "Nair", "Finance", "Financial Analyst", 80000),
("Tom", "Nielsen", "Engineering", "Frontend Developer", 84000),
("Amara", "Diallo", "Operations", "Operations Manager", 91000),
("Sophie", "Dupont", "Marketing", "Content Strategist", 66000),
("Kenji", "Tanaka", "Engineering", "DevOps Engineer", 98000),
("Fatima", "Al-Hassan", "HR", "Recruiter", 62000),
("Marco", "Rossi", "Sales", "Sales Manager", 110000),
("Ingrid", "Larsson", "Finance", "Controller", 105000),
("Yaw", "Asante", "Engineering", "Data Engineer", 93000),
("Mei", "Chen", "Marketing", "Growth Lead", 88000),
("Arjun", "Sharma", "Operations", "Supply Chain Analyst", 74000),
("Elena", "Petrov", "Engineering", "QA Engineer", 78000),
("Noah", "Williams", "Sales", "Business Dev Rep", 65000),
("Hana", "Kimura", "HR", "Payroll Specialist", 60000),
("David", "Osei", "Finance", "Budget Analyst", 77000),
("Clara", "Santos", "Engineering", "Tech Lead", 115000),
("Ravi", "Patel", "Marketing", "SEO Specialist", 69000),
("Awa", "Traoré", "Operations", "Logistics Coordinator", 61000),
("Erik", "Andersen", "Sales", "Enterprise AE", 120000),
("Mia", "Johansson", "Engineering", "Mobile Developer", 89000),
("Oluwaseun", "Adeyemi", "Finance", "Accounts Payable", 59000),
("Lena", "Bauer", "HR", "Training Coordinator", 63000),
("Diego", "Herrera", "Engineering", "Platform Engineer", 97000),
("Aisha", "Mohammed", "Marketing", "Social Media Manager", 67000),
("Takeshi", "Yamamoto", "Operations", "Procurement Specialist", 72000),
("Isabelle", "Leclerc", "Sales", "Inside Sales Rep", 64000),
("Felix", "Brandt", "Engineering", "Security Engineer", 102000),
("Nadia", "Kowalski", "Finance", "FP&A Analyst", 83000),
("Samuel", "Mensah", "HR", "Benefits Administrator", 58000),
("Victoria", "Kozlov", "Engineering", "Machine Learning Engineer", 125000),
("Kai", "Fischer", "Marketing", "Brand Manager", 85000),
("Amira", "Ben-Ali", "Operations", "Project Coordinator", 71000),
("Lucas", "Martins", "Sales", "Regional Manager", 108000),
("Yuna", "Park", "Engineering", "Full Stack Developer", 91000),
("Omar", "Ibrahim", "Finance", "Tax Specialist", 86000),
("Hannah", "Schmidt", "HR", "HR Coordinator", 61000),
("Darius", "Popescu", "Engineering", "Embedded Systems", 94000),
("Chiara", "Ferrari", "Marketing", "Product Marketer", 81000),
("Kwame", "Boateng", "Operations", "Quality Analyst", 68000),
("Alice", "Nguyen", "Sales", "Solutions Consultant", 96000),
("Rafael", "Morales", "Finance", "Treasury Analyst", 79000),
("Leila", "Ahmadi", "Engineering", "Cloud Architect", 130000),
("Jordan", "Campbell", "HR", "Talent Acquisition", 70000),
("Sven", "Eriksson", "Operations", "Facilities Manager", 65000),
("Nneka", "Obi", "Sales", "VP of Sales", 145000),
]
class Command(BaseCommand):
help = "Seed the database with 50 realistic employee records."
def handle(self, *args, **options):
if Employee.objects.exists():
self.stdout.write("Database already seeded -- skipping.")
return
employees = [
Employee(
first_name=first,
last_name=last,
department=dept,
role=role,
salary=salary,
)
for first, last, dept, role, salary in SEED_DATA
]
Employee.objects.bulk_create(employees)
self.stdout.write(self.style.SUCCESS(f"Seeded {len(employees)} employees."))

Run it:

Terminal window
python manage.py seed

The command inserts 50 realistic employee records. It checks whether data already exists, so running it twice does not duplicate rows.

Step 4 — Write the serializer

Create employees/serializers.py:

Python
from rest_framework import serializers
from .models import Employee
class EmployeeSerializer(serializers.ModelSerializer):
"""
Serializer for the Employee model.
ModelSerializer automatically generates fields and validation
from the model definition, so no manual field declarations are needed.
The `id` field is read-only and becomes the rowId value on the frontend.
"""
class Meta:
model = Employee
fields = ["id", "first_name", "last_name", "department", "role", "salary"]
read_only_fields = ["id"]

What’s happening:

  • ModelSerializer inspects the model and generates field definitions and validation rules automatically.
  • id is read-only because the database assigns it — the frontend never sends one for new rows.
  • The fields list controls which columns appear in the API response and therefore which columns Handsontable receives.

Step 5 — Configure pagination

Create employees/pagination.py:

Python
from rest_framework.pagination import PageNumberPagination
from rest_framework.response import Response
class EmployeePagination(PageNumberPagination):
"""
Custom pagination class that reads Handsontable's `pageSize` query parameter.
By default, DRF uses `page_size` as the query param name.
Setting page_size_query_param = 'pageSize' lets DRF read the value that
Handsontable's dataProvider sends automatically, so no URL translation
is needed on the frontend.
The paginate_queryset / get_paginated_response pair is called by DRF
automatically when a ViewSet uses this class.
"""
page_size = 10
page_size_query_param = "pageSize" # matches Handsontable's default param name
max_page_size = 100
def get_paginated_response(self, data):
"""
Return the response shape Handsontable's dataProvider expects:
{ rows: [...], totalRows: N }
DRF's default shape is { count, next, previous, results }.
We remap it here so the frontend fetchRows callback can return
the object directly without any transformation.
"""
return Response(
{
"rows": data,
"totalRows": self.page.paginator.count,
}
)
def get_paginated_response_schema(self, schema):
return {
"type": "object",
"properties": {
"totalRows": {"type": "integer"},
"rows": schema,
},
}

Why a custom pagination class?

DRF’s default response shape is { count, next, previous, results }. Handsontable’s dataProvider expects { rows, totalRows }. Overriding get_paginated_response converts the shape on the server side, so the fetchRows callback on the frontend can return res.json() without any extra transformation.

Why page_size_query_param = 'pageSize'?

Handsontable sends ?pageSize=10 automatically. DRF’s default param name is page_size. Setting page_size_query_param = 'pageSize' lets DRF read Handsontable’s value directly, so no URL translation is needed in fetchRows.

Step 6 — Write the ViewSet

Create employees/views.py:

Python
import json
from django.db import transaction
from django.db.models import Q
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.response import Response
from .models import Employee
from .pagination import EmployeePagination
from .serializers import EmployeeSerializer
# Whitelist for sorting -- also reused by the filter prop check.
ALLOWED_ORDERING_FIELDS = {'first_name', 'last_name', 'department', 'role', 'salary'}
# Numeric fields must use exact (not iexact) to avoid casting errors on DecimalField.
NUMERIC_FIELDS = {'salary'}
# Maps Handsontable Filters condition names to Django ORM lookup suffixes.
# eq / not_eq are resolved dynamically below (numeric vs text distinction).
_CONDITION_LOOKUP = {
'contains': ('icontains', False),
'not_contains': ('icontains', True),
'begins_with': ('istartswith', False),
'ends_with': ('iendswith', False),
'gte': ('gte', False),
'lte': ('lte', False),
'gt': ('gt', False),
'lt': ('lt', False),
}
class EmployeeViewSet(viewsets.ModelViewSet):
"""
ViewSet providing paginated, sortable, and filterable employee data,
plus batch CRUD actions that match Handsontable's dataProvider payload shape.
"""
queryset = Employee.objects.all()
serializer_class = EmployeeSerializer
pagination_class = EmployeePagination
filter_backends = [OrderingFilter, SearchFilter]
ordering_fields = list(ALLOWED_ORDERING_FIELDS)
search_fields = ['first_name', 'last_name', 'department', 'role']
def get_queryset(self):
queryset = Employee.objects.all()
# --- Sort ---
# Handsontable sends sort[prop] + sort[order]; translate to a queryset
# .order_by() call so DRF's OrderingFilter doesn't need to be patched.
sort_prop = self.request.query_params.get('sort[prop]')
sort_order = self.request.query_params.get('sort[order]', 'asc')
if sort_prop and sort_prop in ALLOWED_ORDERING_FIELDS:
prefix = '' if sort_order == 'asc' else '-'
queryset = queryset.order_by(f'{prefix}{sort_prop}')
# --- Filters ---
# dataProvider serializes filters as a JSON array of DataProviderFilterColumn
# objects: [{ prop, operation, conditions: [{ name, args }] }, ...]
#
# Each column can carry multiple conditions (e.g. "between" has two).
# The operation field ('conjunction' | 'disjunction') tells us whether to
# combine them with AND or OR.
filters_json = self.request.query_params.get('filters')
if filters_json:
try:
filter_cols = json.loads(filters_json)
q = Q()
for col in filter_cols:
prop = col.get('prop', '')
operation = col.get('operation', 'conjunction')
conditions = col.get('conditions') or []
if prop not in ALLOWED_ORDERING_FIELDS:
continue
col_q_parts = []
is_numeric = prop in NUMERIC_FIELDS
for cond in conditions:
name = cond.get('name')
args = cond.get('args') or []
value = args[0] if args else None
if name == 'empty':
# DecimalField rejects __exact='' -- use isnull for numeric.
if is_numeric:
col_q_parts.append(Q(**{f'{prop}__isnull': True}))
else:
col_q_parts.append(Q(**{f'{prop}__exact': ''}) | Q(**{f'{prop}__isnull': True}))
continue
if name == 'not_empty':
if is_numeric:
col_q_parts.append(Q(**{f'{prop}__isnull': False}))
else:
col_q_parts.append(~Q(**{f'{prop}__exact': ''}) & ~Q(**{f'{prop}__isnull': True}))
continue
# eq / neq: use exact for numeric, iexact for text.
if name in ('eq', 'neq'):
lookup = f'{prop}__exact' if is_numeric else f'{prop}__iexact'
cond_q = Q(**{lookup: value})
col_q_parts.append(~cond_q if name == 'neq' else cond_q)
continue
if name not in _CONDITION_LOOKUP or value is None:
continue
lookup_suffix, negate = _CONDITION_LOOKUP[name]
lookup = f'{prop}__{lookup_suffix}'
cond_q = Q(**{lookup: value})
col_q_parts.append(~cond_q if negate else cond_q)
if not col_q_parts:
continue
# Combine conditions within this column with AND or OR.
col_q = col_q_parts[0]
for part in col_q_parts[1:]:
if operation == 'disjunction':
col_q |= part
else:
col_q &= part
q &= col_q
queryset = queryset.filter(q)
except (json.JSONDecodeError, TypeError, KeyError):
pass
return queryset
# ------------------------------------------------------------------
# Batch CRUD actions
# ------------------------------------------------------------------
# Standard REST endpoints (POST /employees/, PATCH /employees/{id}/, etc.)
# operate on a single resource at a time. Handsontable's dataProvider
# sends all mutations in a single request as arrays, so we need
# custom @action endpoints that accept arrays.
@action(detail=False, methods=['post'], url_path='create-rows')
@transaction.atomic
def create_rows(self, request):
"""
POST /api/employees/create-rows/
Handsontable's onRowsCreate callback receives { rowsAmount } -- the
number of rows the user wants to add. The backend creates that many
empty rows via bulk_create and returns them with server-assigned ids
so Handsontable can update its internal row map.
Payload shape:
{ rowsAmount: 2 }
"""
rows_amount = max(1, int(request.data.get('rowsAmount') or 1))
employees = Employee.objects.bulk_create([
Employee(first_name='', last_name='', department='', role='', salary=0)
for _ in range(rows_amount)
])
serializer = EmployeeSerializer(employees, many=True)
return Response(serializer.data, status=201)
@action(detail=False, methods=['patch'], url_path='update-rows')
@transaction.atomic
def update_rows(self, request):
"""
PATCH /api/employees/update-rows/
Handsontable's onRowsUpdate callback sends an array of objects with
the row id and a changes dict containing only the modified fields.
Payload shape:
[
{ id: 7, changes: { salary: 102000 } },
{ id: 12, changes: { department: "Marketing", role: "Team Lead" } }
]
"""
updated = []
for row in request.data:
employee = Employee.objects.get(pk=row['id'])
serializer = EmployeeSerializer(employee, data=row['changes'], partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
updated.append(serializer.data)
return Response(updated)
@action(detail=False, methods=['delete'], url_path='remove-rows')
def remove_rows(self, request):
"""
DELETE /api/employees/remove-rows/
Payload shape:
[3, 7, 14]
"""
deleted_count, _ = Employee.objects.filter(pk__in=request.data).delete()
return Response({'deleted': deleted_count})

The key parts are the sort translation and the three batch CRUD actions.

Sort translation

Handsontable sends ?sort[prop]=salary&sort[order]=desc. See get_queryset in views.py for the translation.

What’s happening:

  1. Read Handsontable’s sort[prop] and sort[order] params.
  2. Prepend - for descending order (Django ORM convention).
  3. Call queryset.order_by() directly. ALLOWED_ORDERING_FIELDS is a whitelist that prevents ORM injection through arbitrary field names.

Filter translation

dataProvider passes filters as a single filters query param containing a JSON-encoded array. Each element is a DataProviderFilterColumn object:

[
{
"prop": "department",
"operation": "conjunction",
"conditions": [{ "name": "eq", "args": ["Engineering"] }]
}
]

Decode and build a Django Q object. See get_queryset in views.py for the complete filter translation.

What’s happening:

  • json.loads(filters_json) decodes the single filters param into a list of column filter objects.
  • Each column object contains prop, operation (conjunction = AND, disjunction = OR), and a conditions list.
  • Conditions within one column combine according to operation; columns always combine with AND.
  • eq/neq use exact for salary (DecimalField rejects iexact) and iexact for text fields.
  • empty/not_empty handle both null and blank-string cases for text fields.
  • ALLOWED_ORDERING_FIELDS whitelists the prop value to prevent ORM injection.

Batch CRUD endpoints

Standard REST conventions use single-resource endpoints (POST /employees/, DELETE /employees/{id}/). Handsontable’s dataProvider sends all mutations as arrays in a single request. A DRF @action solves this without creating a separate URL pattern by hand. See create_rows, update_rows, and remove_rows in views.py.

What’s happening:

  • detail=False registers the action at the list URL (/api/employees/) instead of the detail URL (/api/employees/{id}/).
  • create_rows reads rowsAmount from the request and uses bulk_create to insert that many empty rows in one SQL statement. Returning them with their new id values lets dataProvider update its internal row map.
  • partial=True in update_rows allows updating a subset of fields (row['changes']) without requiring all fields to be present.
  • filter(pk__in=ids).delete() removes multiple rows in a single SQL statement.

Why not use standard DELETE /api/employees/{id}/ for each row?

Deleting N rows individually requires N requests. A single batch request is faster and reduces network round trips.

Step 7 — Register URLs

Create employees/urls.py:

Python
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from .views import EmployeeViewSet
# DefaultRouter generates URL patterns for all ViewSet actions automatically:
# GET /api/employees/ -> list()
# POST /api/employees/ -> create() (single-row; use create-rows/ for batch)
# GET /api/employees/{id}/ -> retrieve()
# PUT /api/employees/{id}/ -> update()
# PATCH /api/employees/{id}/ -> partial_update()
# DELETE /api/employees/{id}/ -> destroy()
#
# The custom @action methods are registered automatically too:
# POST /api/employees/create-rows/
# PATCH /api/employees/update-rows/
# DELETE /api/employees/remove-rows/
router = DefaultRouter()
router.register(r"employees", EmployeeViewSet, basename="employee")
urlpatterns = [
path("api/", include(router.urls)),
]

Include this in your project’s root urls.py:

from django.urls import include, path
urlpatterns = [
path('', include('employees.urls')),
]

DefaultRouter generates all standard and custom action URLs automatically. You can verify the registered routes by visiting http://localhost:8000/api/ in a browser.

Step 8 — Configure CORS

The browser blocks cross-origin requests by default. Add django-cors-headers to allow requests from the frontend development server:

Python
# settings.py -- relevant additions only.
# Add these to your existing Django settings file.
INSTALLED_APPS = [
# ... your existing apps ...
"rest_framework",
"corsheaders", # django-cors-headers
"django_filters", # django-filter (optional, for DjangoFilterBackend)
"employees", # your app
]
MIDDLEWARE = [
# CorsMiddleware must come before CommonMiddleware.
"corsheaders.middleware.CorsMiddleware",
"django.middleware.common.CommonMiddleware",
# ... rest of your middleware ...
]
# --- CORS ---
# Allow requests from the frontend dev server.
# In production, restrict this to your actual domain(s).
CORS_ALLOWED_ORIGINS = [
"http://localhost:5173", # Vite dev server
"http://localhost:3000", # Create React App / Next.js dev server
"https://your-production-domain.com",
]
# If your frontend sends credentials (cookies, Authorization headers), also set:
# CORS_ALLOW_CREDENTIALS = True
# --- Django REST Framework ---
REST_FRAMEWORK = {
"DEFAULT_PAGINATION_CLASS": "employees.pagination.EmployeePagination",
"PAGE_SIZE": 10,
"DEFAULT_FILTER_BACKENDS": [
"rest_framework.filters.OrderingFilter",
"rest_framework.filters.SearchFilter",
# Uncomment to add DjangoFilterBackend for exact field matching:
# "django_filters.rest_framework.DjangoFilterBackend",
],
}

Why must CorsMiddleware come before CommonMiddleware?
CorsMiddleware needs to intercept the preflight OPTIONS request before Django’s routing logic handles it. Placing it after CommonMiddleware can result in missing CORS headers on preflight responses.

Production note: Replace the dev server origins with your actual production domain. Never set CORS_ALLOW_ALL_ORIGINS = True in production.

Step 9 — Handle CSRF in the frontend

Django protects mutating endpoints with a CSRF token. It sets a csrftoken cookie on every response. Read it and include it in the X-CSRFToken header for every POST, PATCH, or DELETE request. See getCsrfToken in the code files in Step 11.

Why a cookie instead of a hidden form field?
Handsontable uses fetch(), not HTML form submission. Reading the token from the cookie (SameSite + CSRF double-submit pattern) works with any JavaScript HTTP client without server-side template changes.

Step 10 — Build the URL for fetchRows

Handsontable’s dataProvider calls fetchRows with a { page, pageSize, sort, filters } object. See buildUrl in the code files in Step 11.

What’s happening:

  • page and pageSize are sent as-is. DRF reads pageSize directly because page_size_query_param = 'pageSize' was set in Step 5.
  • sort is split into sort[prop] and sort[order]. The Django view reads both params and calls queryset.order_by() directly (see Step 6).
  • filters is serialised with JSON.stringify. dataProvider passes the full DataProviderFilterColumn array — including operation and nested conditions — which Django decodes with json.loads().
  • API_BASE uses a relative path (/api/employees/). In development, Vite proxies /api/* to http://localhost:8000, so the browser and Django share one origin, which keeps CSRF cookies accessible without extra CORS configuration.

Step 11 — Initialize Handsontable

With the backend and Vite dev server running (bash setup.sh), open http://localhost:5173 to see the grid. The Django API runs on http://localhost:8000 inside Docker; Vite proxies all /api/* requests to it. The complete frontend code is in the files below.

TypeScript
/* file: app.component.ts */
import {
Component,
ViewChild,
ViewEncapsulation,
CUSTOM_ELEMENTS_SCHEMA,
} from '@angular/core';
import { HotTableModule, HotTableComponent } from '@handsontable/angular-wrapper';
import type {
DataProviderQueryParameters,
RowsCreatePayload,
RowUpdatePayload,
RowMutationPayload,
RowMutationRemovePayload,
} from 'handsontable/plugins/dataProvider';
// Vite proxies /api/* → http://localhost:8000, so we use a relative URL.
const API_BASE = '/api/employees/';
// Django sets the csrftoken cookie on every response; read it and forward it
// as X-CSRFToken on mutating requests.
function getCsrfToken(): string {
return (
document.cookie
.split('; ')
.find((row) => row.startsWith('csrftoken='))
?.split('=')[1] ?? ''
);
}
// Django REST Framework reads sort as sort[prop]/sort[order] and filters as
// a JSON-encoded array (parsed in views.py with json.loads).
function buildUrl(params: DataProviderQueryParameters): string {
const query = new URLSearchParams({
page: String(params.page),
pageSize: String(params.pageSize),
});
if (params.sort) {
query.set('sort[prop]', params.sort.prop);
query.set('sort[order]', params.sort.order);
}
if (params.filters?.length) {
query.set('filters', JSON.stringify(params.filters));
}
return `${API_BASE}?${query.toString()}`;
}
@Component({
standalone: true,
encapsulation: ViewEncapsulation.None,
imports: [HotTableModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'example1-server-side-django',
template: `
<div>
<hot-table [settings]="settings"></hot-table>
</div>
`,
})
export class AppComponent {
@ViewChild(HotTableComponent) readonly hotRef!: HotTableComponent;
private removeConfirmed = false;
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(`Fetch failed: ${res.status}`);
// EmployeePagination returns { rows, totalRows } directly.
return res.json();
},
// Fires when the user inserts rows via the context menu.
// payload: { position: 'above'|'below', referenceRowId, rowsAmount }
onRowsCreate: async ({ rowsAmount }: RowsCreatePayload) => {
const res = await fetch(`${API_BASE}create-rows/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken() },
body: JSON.stringify({ rowsAmount }),
});
if (!res.ok) throw new Error(`Create failed: ${res.status}`);
const data = await res.json();
const info = data.map((r: { id: number }) => `(id: ${r.id})`).join(', ');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this.hotRef.hotInstance!.getPlugin('notification') as any).showMessage({
variant: 'success',
title: 'Row added',
message: `Created: ${info}`,
duration: 3000,
});
return data;
},
// Fires after a cell edit, paste, or autofill batch.
// rows: [{ id, changes: { field: value } }, ...]
onRowsUpdate: async (rows: RowUpdatePayload[]) => {
const res = await fetch(`${API_BASE}update-rows/`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken() },
body: JSON.stringify(rows),
});
if (!res.ok) throw new Error(`Update failed: ${res.status}`);
},
// Fires after the user confirms deletion.
onRowsRemove: async (rowIds: unknown[]) => {
const res = await fetch(`${API_BASE}remove-rows/`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken() },
body: JSON.stringify(rowIds),
});
if (!res.ok) throw new Error(`Delete failed: ${res.status}`);
},
},
// beforeRowsMutation is sync (checks for a strict `=== false` return), so
// we can't await an async prompt inline. Instead: cancel the original
// attempt, show a notification with Delete/Cancel actions, and on Delete
// re-issue the remove via the DataProvider API. The flag lets the second
// pass through without re-prompting.
beforeRowsMutation: (operation: 'create' | 'update' | 'remove', payload: RowMutationPayload): false | void => {
if (operation === 'remove' && !this.removeConfirmed) {
const { rowsRemove } = payload as RowMutationRemovePayload;
const hot = this.hotRef.hotInstance!;
const count = rowsRemove.length;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const notification = (hot.getPlugin('notification') as any);
const id = notification.showMessage({
variant: 'warning',
title: 'Delete rows',
message: `Delete ${count} row${count !== 1 ? 's' : ''}? This cannot be undone.`,
duration: 0,
actions: [
{
label: 'Delete',
type: 'primary',
callback: () => {
notification.hide(id);
this.removeConfirmed = true;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(hot.getPlugin('dataProvider') as any)
.removeRows(rowsRemove)
.finally(() => {
this.removeConfirmed = false;
});
},
},
{
label: 'Cancel',
type: 'secondary',
callback: () => notification.hide(id),
},
],
});
return false;
}
},
pagination: { pageSize: 10 },
columnSorting: true,
filters: true,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
dropdownMenu: ['filter_by_condition', 'filter_action_bar'] as any,
contextMenu: true,
emptyDataState: true,
notification: true,
dialog: true,
colHeaders: ['First Name', 'Last Name', 'Department', 'Role', 'Salary'],
columns: [
{ data: 'first_name', type: 'text' },
{ data: 'last_name', type: 'text' },
{ data: 'department', type: 'text' },
{ data: 'role', type: 'text' },
{ data: 'salary', type: 'numeric', numericFormat: { pattern: '$0,0' } },
],
rowHeaders: true,
height: 400,
width: '100%',
autoWrapRow: true,
licenseKey: 'non-commercial-and-evaluation',
};
}
/* 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-django></example1-server-side-django>
</div>

Key options explained:

OptionWhat it does
rowId: 'id'Tells dataProvider which field identifies a row. Must match the serializer field name.
{ signal } in fetchRowsPass the AbortSignal to fetch() so in-flight requests are cancelled when the user sorts or filters before the previous response arrives.
return res.json() in onRowsCreateReturn the server response so dataProvider can update its internal row map with the server-assigned id values.
pagination: { pageSize: 10 }Enables the pagination toolbar. dataProvider sends the current page and size to fetchRows automatically.
columnSorting: trueEnables column header click-to-sort. The sort state is passed to fetchRows on each change.
filters: trueEnables the column filter UI. Active conditions are passed to fetchRows on each change.
emptyDataState: trueShows a friendly illustration when fetchRows returns zero rows (for example, when a filter matches nothing).
notification: trueShows automatic error toast notifications when fetchRows or a mutation callback throws. Fetch failures include a Refetch action.
beforeRowsMutationIntercepts delete operations before they are sent. Used here to show a confirmation dialog; the actual delete is re-issued after the user confirms.
contextMenu: trueEnables the right-click context menu with “Insert row above / below” and “Remove row” items.

How it works — Complete flow

  1. Initial load: dataProvider calls fetchRows({ page: 1, pageSize: 10 }). The view returns the first 10 rows and the total row count.
  2. User clicks a column header: columnSorting updates its sort state and dataProvider calls fetchRows again with sort: { prop: 'salary', order: 'desc' }. The Django view translates this to ?ordering=-salary for OrderingFilter.
  3. User applies a column filter: The filter UI updates its condition list and dataProvider calls fetchRows with the filters array. The Django view decodes the filters JSON string with json.loads() and builds a Django Q object from each column’s conditions list.
  4. User navigates to page 2: dataProvider calls fetchRows({ page: 2, pageSize: 10, ... }).
  5. User edits a cell: dataProvider collects all changed cells for that row and calls onRowsUpdate with [{ id: 7, salary: 102000 }]. The update-rows endpoint applies a partial update.
  6. User adds a row: dataProvider calls onRowsCreate with the new row values. The create-rows endpoint inserts the row and returns it with an id. dataProvider updates its internal map so future edits use the correct id.
  7. User deletes rows: dataProvider calls onRowsRemove with the selected row ids. The remove-rows endpoint deletes all matching rows in a single SQL statement.

What you learned

  • DRF’s default response shape ({ count, results }) differs from what dataProvider expects ({ rows, totalRows }). Override get_paginated_response in a custom pagination class to map the shape on the server.
  • Set page_size_query_param = 'pageSize' so DRF reads Handsontable’s parameter name directly.
  • Read Handsontable’s sort[prop] and sort[order] params in get_queryset and call queryset.order_by() directly. Whitelist the field name with ALLOWED_ORDERING_FIELDS to prevent ORM injection.
  • Receive Handsontable’s filters param as a JSON string and decode it with json.loads(). Each entry is { prop, operation, conditions: [{ name, args }] } — conditions within a column combine with AND or OR; columns always combine with AND.
  • Use DRF @action endpoints for batch CRUD instead of single-resource REST routes.
  • Read Django’s CSRF token from the csrftoken cookie and include it as the X-CSRFToken header in all mutating requests.
  • Place CorsMiddleware before CommonMiddleware so preflight requests receive CORS headers.

Next steps