Server-side data with Django
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.
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
Install the Python dependencies:
pip install django djangorestframework django-cors-headersInstall the JavaScript dependency:
npm install handsontableStep 1 — Set up the Django app
Create a Django project and a employees app:
django-admin startproject myproject .python manage.py startapp employeesWhy 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:
INSTALLED_APPS = [ # ... 'rest_framework', 'corsheaders', 'employees',]Step 2 — Define the Employee model
Create the model in employees/models.py:
from django.db import models
class Employee(models.Model): first_name = models.CharField(max_length=100) last_name = models.CharField(max_length=100) department = models.CharField(max_length=100) role = models.CharField(max_length=100) salary = models.DecimalField(max_digits=10, decimal_places=2)
class Meta: ordering = ['last_name', 'first_name']What’s happening:
DecimalFieldstores salary without floating-point rounding errors — important for currency values.orderinginMetasets the default query order. TheOrderingFilteroverrides it when the user sorts a column.- Django automatically adds an
idprimary key. This becomes therowIdvalue on the frontend.
Run migrations to create the database table:
python manage.py makemigrations employeespython manage.py migrateStep 3 — Seed the database
Create the seed command file at employees/management/commands/seed.py (see server/seed_command.py in this recipe) and run it:
python manage.py seedThe 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:
from rest_framework import serializersfrom .models import Employee
class EmployeeSerializer(serializers.ModelSerializer): class Meta: model = Employee fields = ['id', 'first_name', 'last_name', 'department', 'role', 'salary'] read_only_fields = ['id']What’s happening:
ModelSerializerinspects the model and generates field definitions and validation rules automatically.idis read-only because the database assigns it — the frontend never sends one for new rows.- The
fieldslist controls which columns appear in the API response and therefore which columns Handsontable receives.
Step 5 — Configure pagination
Create employees/pagination.py:
from rest_framework.pagination import PageNumberPaginationfrom rest_framework.response import Response
class EmployeePagination(PageNumberPagination): 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 Response({ 'rows': data, 'totalRows': self.page.paginator.count, })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. The key parts are the sort translation and the three batch CRUD actions.
Sort translation
Handsontable sends ?sort[prop]=salary&sort[order]=desc. DRF’s OrderingFilter expects ?ordering=-salary. Translate in get_queryset:
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 self.ordering_fields: ordering = sort_prop if sort_order == 'asc' else f'-{sort_prop}' self.request._request.GET = self.request._request.GET.copy() self.request._request.GET['ordering'] = orderingWhat’s happening:
- Read Handsontable’s
sort[prop]andsort[order]params. - Prepend
-for descending order (DRF convention). - Inject the translated value into the mutable query params copy so
OrderingFilterpicks it up as if the frontend had sent?ordering=directly.
Filter translation
Handsontable sends filters as:
?filters[0][prop]=department&filters[0][value]=Engineering&filters[0][condition]=eqParse these and build a Django Q object:
q = Q()index = 0
while index < 20: prefix = f'filters[{index}]' prop = self.request.query_params.get(f'{prefix}[prop]') value = self.request.query_params.get(f'{prefix}[value]') condition = self.request.query_params.get(f'{prefix}[condition]', 'contains')
if prop is None: break
lookup_map = { 'contains': f'{prop}__icontains', 'not_contains': f'{prop}__icontains', # negated below 'eq': f'{prop}__iexact', 'begins_with': f'{prop}__istartswith', 'ends_with': f'{prop}__iendswith', 'gte': f'{prop}__gte', 'lte': f'{prop}__lte', }
lookup = lookup_map.get(condition)
if lookup: if condition in ('not_contains',): q &= ~Q(**{lookup: value}) else: q &= Q(**{lookup: value})
index += 1
return queryset.filter(q)What’s happening:
- The loop reads up to 20 filter conditions from the query string. Multiple conditions are combined with
ANDusing&=. icontains,istartswith, andiendswithare case-insensitive Django ORM lookups.not_containsnegates theQobject with~.gteandltework for numeric fields likesalary.
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:
@action(detail=False, methods=['post'], url_path='create-rows')def create_rows(self, request): serializer = EmployeeSerializer(data=request.data, many=True) serializer.is_valid(raise_exception=True) serializer.save() return Response(serializer.data, status=201)
@action(detail=False, methods=['patch'], url_path='update-rows')def update_rows(self, request): updated = [] for row in request.data: employee = Employee.objects.get(pk=row['id']) serializer = EmployeeSerializer(employee, data=row, 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): deleted_count, _ = Employee.objects.filter(pk__in=request.data).delete() return Response({'deleted': deleted_count})What’s happening:
detail=Falseregisters the action at the list URL (/api/employees/) instead of the detail URL (/api/employees/{id}/).many=Trueon the serializer tells DRF to accept and validate an array of objects at once.partial=Trueinupdate_rowsallows updating a subset of fields 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:
from django.urls import include, pathfrom rest_framework.routers import DefaultRouterfrom .views import EmployeeViewSet
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:
MIDDLEWARE = [ 'corsheaders.middleware.CorsMiddleware', 'django.middleware.common.CommonMiddleware', # ... rest of your middleware ...]
CORS_ALLOWED_ORIGINS = [ 'http://localhost:5173', # Vite dev server 'http://localhost:3000', # CRA / Next.js dev server]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:
function getCsrfToken() { return document.cookie .split('; ') .find((row) => row.startsWith('csrftoken=')) ?.split('=')[1];}Pass the token in the request headers:
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken(),},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. Translate these into query string parameters:
function buildUrl(base, { page, pageSize, sort, filters }) { const params = new URLSearchParams();
params.set('page', page); params.set('pageSize', pageSize);
if (sort?.prop) { params.set('sort[prop]', sort.prop); params.set('sort[order]', sort.order ?? 'asc'); }
if (filters?.length) { filters.forEach(({ prop, value, condition }, i) => { params.set(`filters[${i}][prop]`, prop); params.set(`filters[${i}][value]`, value); params.set(`filters[${i}][condition]`, condition); }); }
return `${base}?${params.toString()}`;}What’s happening:
pageandpageSizeare sent as-is. DRF readspageSizedirectly becausepage_size_query_param = 'pageSize'was set in Step 5.sortis split intosort[prop]andsort[order]. The Django view reassembles them into DRF’sorderingparam (see Step 6).- Each filter condition becomes a
filters[N][prop],filters[N][value],filters[N][condition]triplet. The Django view parses this indexed format in a loop.
Step 11 — Initialize Handsontable
Wire everything together in the dataProvider configuration:
const hot = new Handsontable(container, { dataProvider: { rowId: 'id',
fetchRows: async ({ page, pageSize, sort, filters }, { signal }) => { const url = buildUrl('http://localhost:8000/api/employees/', { page, pageSize, sort, filters, }); const res = await fetch(url, { signal });
if (!res.ok) throw new Error(`Fetch failed: ${res.status}`);
// pagination.py already maps { count, results } to { rows, totalRows }. return res.json(); },
onRowsCreate: async (rows) => { const res = await fetch('.../create-rows/', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken() }, body: JSON.stringify(rows), }); return res.json(); // return created rows so dataProvider updates its row map },
onRowsUpdate: async (rows) => { await fetch('.../update-rows/', { method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken() }, body: JSON.stringify(rows), }); },
onRowsRemove: async (rowIds) => { await fetch('.../remove-rows/', { method: 'DELETE', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken() }, body: JSON.stringify(rowIds), }); }, },
pagination: { pageSize: 10 }, columnSorting: true, filters: true, dropdownMenu: ['filter_by_condition', 'filter_action_bar'], emptyDataState: true, notification: 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, licenseKey: 'non-commercial-and-evaluation',});@code
Key options explained:
| Option | What it does |
|---|---|
rowId: 'id' | Tells dataProvider which field identifies a row. Must match the serializer field name. |
{ signal } in fetchRows | Pass 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 onRowsCreate | Return 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: true | Enables column header click-to-sort. The sort state is passed to fetchRows on each change. |
filters: true | Enables the column filter UI. Active conditions are passed to fetchRows on each change. |
emptyDataState: true | Shows a friendly illustration when fetchRows returns zero rows (for example, when a filter matches nothing). |
notification: true | Shows automatic error toast notifications when fetchRows or a mutation callback throws. Fetch failures include a Refetch action. |
How it works — Complete flow
- Initial load:
dataProvidercallsfetchRows({ page: 1, pageSize: 10 }). The view returns the first 10 rows and the total row count. - User clicks a column header:
columnSortingupdates its sort state anddataProvidercallsfetchRowsagain withsort: { prop: 'salary', order: 'desc' }. The Django view translates this to?ordering=-salaryforOrderingFilter. - User applies a column filter: The filter UI updates its condition list and
dataProvidercallsfetchRowswith thefiltersarray. The Django view parsesfilters[0][prop],filters[0][value], andfilters[0][condition]into a DjangoQobject. - User navigates to page 2:
dataProvidercallsfetchRows({ page: 2, pageSize: 10, ... }). - User edits a cell:
dataProvidercollects all changed cells for that row and callsonRowsUpdatewith[{ id: 7, salary: 102000 }]. Theupdate-rowsendpoint applies a partial update. - User adds a row:
dataProvidercallsonRowsCreatewith the new row values. Thecreate-rowsendpoint inserts the row and returns it with anid.dataProviderupdates its internal map so future edits use the correct id. - User deletes rows:
dataProvidercallsonRowsRemovewith the selected row ids. Theremove-rowsendpoint deletes all matching rows in a single SQL statement.
What you learned
- DRF’s default response shape (
{ count, results }) differs from whatdataProviderexpects ({ rows, totalRows }). Overrideget_paginated_responsein 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. - Translate Handsontable’s
sort[prop]+sort[order]params to DRF’sorderingparam inget_querysetbeforeOrderingFilterruns. - Parse Handsontable’s indexed
filters[N][...]params into DjangoQobjects to support column-level filtering. - Use DRF
@actionendpoints for batch CRUD instead of single-resource REST routes. - Read Django’s CSRF token from the
csrftokencookie and include it as theX-CSRFTokenheader in all mutating requests. - Place
CorsMiddlewarebeforeCommonMiddlewareso preflight requests receive CORS headers.
Next steps
- Server-side data documentation — full
dataProviderAPI reference - Rows pagination guide
- Column filter guide
- Rows sorting guide