Server-side data with Supabase
This tutorial shows how to wire Handsontable’s dataProvider plugin to a live Supabase Postgres table. Every page load, sort, filter, and cell edit goes through the database, so the browser never holds the full dataset.
Overview
Difficulty: Intermediate
Time: ~30 minutes
Stack: React, Vite, Handsontable dataProvider, @handsontable/react-wrapper, @supabase/supabase-js v2
The dataProvider plugin turns Handsontable into a server-backed grid. You supply four async functions — one to fetch a page of rows, and three to create, update, and delete them — and the plugin drives pagination, sorting, and filtering against the server. This recipe connects those functions to a Supabase Postgres table through PostgREST, the REST API that every Supabase project exposes automatically.
What You’ll Build
An inventory grid backed by a live Supabase table:
- Page-by-page loading with server-side pagination.
- Server-side sorting through PostgREST’s
.order()method. - Server-side filtering, with each Handsontable filter condition mapped to its PostgREST equivalent.
- Full CRUD: cell edits, new rows, and deletions are written to the database and confirmed by the server.
- Error toasts through the Notification plugin.
Before you begin
| Requirement | Version |
|---|---|
handsontable | 17.1 or later |
@handsontable/react-wrapper | 17.1 or later |
@supabase/supabase-js | 2 |
| React + Vite | any current version |
| Supabase project | the free plan is enough |
This recipe adds to an existing Vite + React + TypeScript app. If you do not have one, scaffold it first, then add the Handsontable and Supabase packages:
npm create vite@latest my-inventory-app -- --template react-tscd my-inventory-appnpm install handsontable @handsontable/react-wrapper @supabase/supabase-jsThe grid uses an inventory dataset with seven columns: id, SKU, name, quantity, unit price, warehouse, and an updated-at timestamp. With enough rows the grid pages through the data, so server-side pagination is meaningful from the first load.
Create the database table
Run this SQL in the Supabase SQL Editor to create the
inventorytable, enable Row Level Security, and seed a few rows:create table inventory (id uuid primary key default gen_random_uuid(),sku text not null,name text not null,quantity integer not null default 0,unit_price numeric(10,2) not null default 0,warehouse text not null,updated_at timestamptz not null default now());-- Keep updated_at current on every update; the client never sends this field.create or replace function set_updated_at()returns trigger language plpgsql as $$beginnew.updated_at = now();return new;end;$$;create trigger inventory_set_updated_atbefore update on inventoryfor each row execute function set_updated_at();-- Enable Row Level Security. The table is private until you add a policy.alter table inventory enable row level security;-- Grant the Data API roles access to the table. This is required as of-- April 2026, when Supabase stopped exposing new tables automatically.grant select, insert, update, delete on table inventory to anon, authenticated;-- A permissive starter policy. Replace it with the per-user rules in Step 7.create policy "anon full access"on inventory for allto anonusing (true)with check (true);insert into inventory (sku, name, quantity, unit_price, warehouse) values('SKU-001', 'Widget A', 120, 5.99, 'East'),('SKU-002', 'Widget B', 45, 7.49, 'West'),('SKU-003', 'Gadget Pro', 200, 24.99, 'East'),('SKU-004', 'Gadget Lite', 30, 14.99, 'Central'),('SKU-005', 'Connector Cable', 88, 9.99, 'West'),('SKU-006', 'Power Adapter', 150, 19.99, 'East'),('SKU-007', 'USB Hub 4-port', 60, 29.99, 'Central'),('SKU-008', 'Monitor 27"', 12, 349.99, 'West');What’s happening:
The
set_updated_attrigger fires on everyUPDATEand writes the current time toupdated_at, so the client never needs to send that field. Enabling Row Level Security blocks all access by default; theanonrole reaches the table through the Data API only because of the explicitgrant. Row-level rules come from the policies you add on top, starting with the permissiveanon full accesspolicy here.Configure environment variables
Add your Supabase project URL and anon key to
.env.local:VITE_SUPABASE_URL=https://<your-project-ref>.supabase.coVITE_SUPABASE_ANON_KEY=<your-anon-key>Find these values in the Supabase dashboard under Project Settings -> API.
What’s happening:
Vite exposes any variable prefixed with
VITE_to the browser bundle. The anon key is meant to be public: it identifies your project but grants no permissions on its own. Access is controlled by the RLS policies in the database, not by keeping the key secret.Create the Supabase client
Create
src/supabase.ts, a single client instance shared across the app:TypeScript import { createClient } from '@supabase/supabase-js';// Created once at module load and shared across the app. PostgREST manages// connection pooling on the server, so there is nothing to configure here.export const supabase = createClient(import.meta.env.VITE_SUPABASE_URL,import.meta.env.VITE_SUPABASE_ANON_KEY,);What’s happening:
createClientruns once at module load and is exported as a singleton. PostgREST manages connection pooling on the server, so there is nothing to configure on the client. Every function that queries Supabase imports this one instance.Map filter conditions to PostgREST
When the Filters plugin runs with
dataProvider, Handsontable passes the current filter state asfilterson every fetch. Each entry describes one column: aprop(the column data key), an array ofconditions(each with anameandargs), and anoperation.Map each condition name to its PostgREST equivalent:
Handsontable condition argsSupabase call eq[value].eq(col, value)neq[value].neq(col, value)contains[value].ilike(col, '%value%')not_contains[value].not(col, 'ilike', '%value%')begins_with[value].ilike(col, 'value%')ends_with[value].ilike(col, '%value')gt[value].gt(col, value)gte[value].gte(col, value)lt[value].lt(col, value)lte[value].lte(col, value)between[from, to].gte(col, low).lte(col, high)not_between[from, to].or('col.lt.low,col.gt.high')empty[].or('col.is.null,col.eq.')not_empty[].not(col, 'is', null).neq(col, '')Put the mapping in
src/filterAdapter.ts:TypeScript import type { SupabaseClient } from '@supabase/supabase-js';import type { DataProviderQueryParameters } from 'handsontable/plugins/dataProvider';// The query builder returned by supabase.from('inventory').select(...).type QueryBuilder = ReturnType<ReturnType<SupabaseClient['from']>['select']>;// One column's filter state, taken directly from the dataProvider query parameters.type FilterColumn = NonNullable<DataProviderQueryParameters['filters']>[number];type FilterCondition = FilterColumn['conditions'][number];// `between` / `not_between` inputs can arrive in either order; Handsontable sorts// them before comparing. Both are offered only on numeric columns (here: quantity,// unit_price), so normalize to a numeric [low, high] range.function numericRange(args: unknown[]): [number, number] {const a = Number(args[0]);const b = Number(args[1]);return [Math.min(a, b), Math.max(a, b)];}// Conjunction: chain each condition onto the builder. PostgREST ANDs chained calls.function applyCondition(query: QueryBuilder, column: string, condition: FilterCondition): QueryBuilder {const { name, args } = condition;const value = args[0];switch (name) {case 'eq':return query.eq(column, value as string);case 'neq':return query.neq(column, value as string);case 'contains':return query.ilike(column, `%${value}%`);case 'not_contains':return query.not(column, 'ilike', `%${value}%`);case 'begins_with':return query.ilike(column, `${value}%`);case 'ends_with':return query.ilike(column, `%${value}`);case 'gt':return query.gt(column, value as number);case 'gte':return query.gte(column, value as number);case 'lt':return query.lt(column, value as number);case 'lte':return query.lte(column, value as number);case 'between': {const [low, high] = numericRange(args);return query.gte(column, low).lte(column, high);}case 'not_between': {const [low, high] = numericRange(args);return query.or(`${column}.lt.${low},${column}.gt.${high}`);}case 'empty':return query.or(`${column}.is.null,${column}.eq.`);case 'not_empty':return (query.not(column, 'is', null) as QueryBuilder).neq(column, '');default:console.warn(`[dataProvider] Unsupported filter condition: "${name}" - skipping`);return query;}}// Disjunction: each condition becomes a PostgREST `.or()` term. Inside `.or()`,// wildcards use `*` (not `%`) and negation uses the `not.` prefix. Values that// contain commas or parentheses would need quoting; the inventory data has none.function conditionToOrTerm(column: string, condition: FilterCondition): string | null {const { name, args } = condition;const value = args[0];switch (name) {case 'eq':return `${column}.eq.${value}`;case 'neq':return `${column}.neq.${value}`;case 'contains':return `${column}.ilike.*${value}*`;case 'not_contains':return `${column}.not.ilike.*${value}*`;case 'begins_with':return `${column}.ilike.${value}*`;case 'ends_with':return `${column}.ilike.*${value}`;case 'gt':return `${column}.gt.${value}`;case 'gte':return `${column}.gte.${value}`;case 'lt':return `${column}.lt.${value}`;case 'lte':return `${column}.lte.${value}`;case 'between': {const [low, high] = numericRange(args);return `and(${column}.gte.${low},${column}.lte.${high})`;}case 'not_between': {const [low, high] = numericRange(args);return `${column}.lt.${low},${column}.gt.${high}`;}case 'empty':return `${column}.is.null,${column}.eq.`;case 'not_empty':return `and(${column}.not.is.null,${column}.neq.)`;default:console.warn(`[dataProvider] Unsupported filter condition: "${name}" - skipping`);return null;}}export function applyFilters(query: QueryBuilder,filters: DataProviderQueryParameters['filters'],): QueryBuilder {if (!filters || filters.length === 0) {return query;}for (const column of filters) {if (column.operation === 'disjunction') {// OR within the column: combine the conditions into a single .or() call.const terms = column.conditions.map(condition => conditionToOrTerm(column.prop, condition)).filter((term): term is string => term !== null);if (terms.length > 0) {query = query.or(terms.join(','));}} else {// Conjunction (the default): chain the conditions so PostgREST ANDs them.for (const condition of column.conditions) {query = applyCondition(query, column.prop, condition);}}}return query;}What’s happening:
applyFiltersreads each column’soperation. For the defaultconjunction, it chains the conditions withapplyCondition— PostgREST ANDs chained calls, so the conditions are ANDed. Fordisjunction(the “Or” toggle in the column’s filter menu), it maps the conditions to PostgREST.or()terms and applies them in a single.or()call, so they are ORed. Inside.or()terms, wildcards use*instead of%and negation uses thenot.prefix; values containing commas or parentheses would need quoting. An unrecognized condition is logged and skipped rather than throwing, so an unmapped filter never breaks a fetch.Build the grid component
Create
src/InventoryGrid.tsx. It imports the client and the filter adapter as siblings (./supabase,./filterAdapter), supplies the fourdataProviderfunctions, and configures the columns. The types come fromhandsontable/plugins/dataProvider, so the query parameters and mutation payloads are checked at compile time:TypeScript import { HotTable } from '@handsontable/react-wrapper';import { registerAllModules } from 'handsontable/registry';import type {DataProviderQueryParameters,DataProviderFetchOptions,DataProviderFetchResult,RowsCreatePayload,RowUpdatePayload,} from 'handsontable/plugins/dataProvider';import 'handsontable/styles/handsontable.min.css';import 'handsontable/styles/ht-theme-main.min.css';import { supabase } from './supabase';import { applyFilters } from './filterAdapter';registerAllModules();async function fetchRows({ page, pageSize, sort, filters }: DataProviderQueryParameters,{ signal }: DataProviderFetchOptions,): Promise<DataProviderFetchResult> {const from = (page - 1) * pageSize;const to = from + pageSize - 1;let query = supabase.from('inventory').select('*', { count: 'exact' }).range(from, to).abortSignal(signal);if (sort) {query = query.order(sort.prop, { ascending: sort.order === 'asc' });}query = applyFilters(query, filters);const { data, count, error } = await query;if (signal.aborted) {return { rows: [], totalRows: 0 };}if (error) {throw error;}return { rows: data ?? [], totalRows: count ?? 0 };}async function onRowsCreate({ rowsAmount }: RowsCreatePayload): Promise<unknown[]> {const newRows = Array.from({ length: rowsAmount }, () => ({sku: 'NEW-000',name: 'New item',quantity: 0,warehouse: 'East',}));const { data, error } = await supabase.from('inventory').insert(newRows).select();if (error) {throw error;}return data;}async function onRowsUpdate(rows: RowUpdatePayload[]): Promise<unknown[]> {const results = await Promise.all(rows.map(({ id, changes }) =>supabase.from('inventory').update(changes).eq('id', id).select().single(),),);const failed = results.find((result) => result.error);if (failed) {throw failed.error;}return results.map((result) => result.data);}async function onRowsRemove(rowIds: string[]): Promise<void> {const { error } = await supabase.from('inventory').delete().in('id', rowIds);if (error) {throw error;}}const ExampleComponent = () => {return (<HotTablethemeName="ht-theme-main"dataProvider={{rowId: 'id',fetchRows,onRowsCreate,onRowsUpdate,onRowsRemove,}}colHeaders={['ID', 'SKU', 'Name', 'Quantity', 'Unit price', 'Warehouse', 'Updated at']}columns={[{ data: 'id', type: 'text', readOnly: true, width: 260 },{ data: 'sku', type: 'text', width: 100 },{ data: 'name', type: 'text', width: 180 },{ data: 'quantity', type: 'numeric', width: 90 },{ data: 'unit_price', type: 'numeric', numericFormat: { pattern: '$0,0.00' }, width: 110 },{ data: 'warehouse', type: 'text', width: 110 },{ data: 'updated_at', type: 'text', readOnly: true, width: 200 },]}hiddenColumns={{ columns: [0] }}fixedColumnsStart={2}pagination={{ pageSize: 10 }}columnSorting={true}filters={true}dropdownMenu={['filter_by_condition', 'filter_action_bar']}notification={true}emptyDataState={true}contextMenu={true}rowHeaders={true}height={500}stretchH="all"licenseKey="non-commercial-and-evaluation"/>);};export default ExampleComponent;What’s happening:
fetchRowsconverts the page number to a PostgREST range and passescount: 'exact', so a single HTTP call returns both the page of rows and the total count. TheAbortSignalfrom Handsontable is passed straight to Supabase: when the user pages or sorts before a response arrives, the in-flight request is canceled and the stale result is discarded.onRowsCreatechains.select()after.insert()so PostgREST returns the inserted rows, including the server-assignedidandupdated_at. Returning those values is required: Handsontable uses theidto track new rows for later updates and deletes.onRowsUpdatereceives all changes from one user action as a single array and dispatches them in parallel withPromise.all. This is safe because Handsontable serializes mutation calls, so there is never more than oneonRowsUpdatein flight at once.hiddenColumnsandfixedColumnsStartwork together. Theidcolumn is hidden (physical index 0), sofixedColumnsStart={2}freezes the two visible columnsskuandnamerather thanidandsku.dropdownMenuomitsfilter_by_value(the checkbox list of distinct values). In server mode the list could only be built from the loaded page, sodataProviderdrops the menu item and ignores anyby_valuecondition added in code. See Filtering by a Set of Values for the server-side alternative.
Render the grid from your app’s entry point — for example, in
src/App.tsx, import the component (import InventoryGrid from './InventoryGrid') and return<InventoryGrid />.Add error handling
notification: truewires up Handsontable’s built-in toast layer, so errors surface in the UI with no extra setup. The two error hooks let you add behavior on top, such as logging to an error tracker:afterDataProviderFetchError={(error, queryParameters) => {// error - the rejection reason from fetchRows// queryParameters - page, pageSize, sort, filters at the time of failurereportToErrorTracker(error, { context: queryParameters });}}afterRowsMutationError={(error) => {// Handsontable has already rolled back the optimistic UI update here.reportToErrorTracker(error);}}What’s happening:
The Notification plugin listens for
afterDataProviderFetchErrorandafterRowsMutationErrorinternally and fires the toast whether or not you attach a hook. The plugin reads a human-readable message from themessage,error, ordetailproperty of the rejection. Supabase’sPostgrestErrorhas amessageproperty, so the toast text is meaningful by default.Lock down access with Row Level Security
The
VITE_SUPABASE_ANON_KEYyou embed in the frontend is public by design. The key identifies your project but grants no permissions on its own; access is controlled entirely by RLS policies. Replace the permissive starter policy from Step 1 with rules that match your app.A per-user ownership pattern:
alter table inventory add column owner_id uuid references auth.users;create policy "select own rows"on inventory for selectusing ((select auth.uid()) = owner_id);create policy "insert own rows"on inventory for insert to authenticatedwith check ((select auth.uid()) = owner_id);create policy "update own rows"on inventory for updateusing ((select auth.uid()) = owner_id)with check ((select auth.uid()) = owner_id);create policy "delete own rows"on inventory for deleteusing ((select auth.uid()) = owner_id);A public-read, authenticated-write pattern:
create policy "public read"on inventory for select to anon, authenticatedusing (true);create policy "authenticated write"on inventory for all to authenticatedusing (true) with check (true);What’s happening:
RLS policies are the real access-control layer. An empty policy list blocks all rows, so enabling RLS without adding policies locks the table completely. Write
(select auth.uid())rather thanauth.uid()inUSINGandWITH CHECKclauses: the scalar subquery form is evaluated once per query instead of once per row, which matters on large tables.
How It Works - Complete Flow
ExampleComponentmounts and Handsontable callsfetchRowswith{ page: 1, pageSize: 10 }.fetchRowsrequestsrange(0, 9)withcount: 'exact'. PostgREST returns the first ten rows and the total count in one HTTP call. Handsontable renders the page and sets up the pagination control.- The user clicks a column header. Handsontable calls
fetchRowsagain with asortparameter, and the in-flight request from step 2 is canceled through theAbortSignal. - The user opens the filter dropdown and sets a condition. Handsontable calls
fetchRowswith afiltersarray. The adapter maps each condition name to a PostgREST method and chains it onto the query. - The user edits a cell and moves away. Handsontable calls
onRowsUpdatewith[{ id, changes }]. The update is sent to Supabase, theset_updated_attrigger fires, and the returned row, with its refreshed timestamp, is written back into the grid. - The user right-clicks and inserts a row. Handsontable calls
onRowsCreatewith{ rowsAmount: 1 }. The new row is inserted, and the server-assignedidandupdated_atcome back in the response. Handsontable stores theidso it can update or delete that row later. - The user selects rows and deletes them. Handsontable calls
onRowsRemovewith the array ofidvalues, which are passed to a singleDELETE ... IN (...)statement.
Performance and Production Considerations
The steps above cover the happy path. For production workloads, add the following:
- Index the columns you sort and filter on. PostgREST runs every sort and filter as SQL, so an unindexed table does a full scan on each request. Add B-tree indexes on the exposed columns, for example
create index on inventory (sku);andcreate index on inventory (warehouse);. A composite index helps when users combine conditions on the same columns. - Batch large edits.
onRowsUpdatesends one request per changed row. A single cell edit is one request, but pasting a block produces many. For bulk writes, send the changes to a Postgres function throughsupabase.rpc(), or use one.upsert()call, so the round trip is a single request instead of N. - Index the RLS ownership column. The per-user policy in Step 7 filters every query by
owner_id. Addcreate index on inventory (owner_id);and keep the(select auth.uid())form so the check runs once per query, not once per row. Setowner_idon insert with a column default ofauth.uid()or abefore inserttrigger, so the client never sends it. - Tune the count on large tables.
count: 'exact'runs a fullCOUNT(*)on every fetch, which grows costly past a few hundred thousand rows. Switch the pagination total tocount: 'estimated'(or'planned') on large tables, and keeppageSizemodest so each page stays a small range scan.
Filtering by a Set of Values
The built-in Filter by value checkbox list is unavailable in server mode: the list could only be built from the loaded page, so dataProvider omits the menu item and ignores by_value conditions. The condition-based filters from Step 4 cover most cases, but you can rebuild value-style filtering with your own control.
- Populate a multi-select with the column’s distinct values from the server, for example
select distinct warehouse from inventory order by warehouse. Put this behind a cached view or an RPC so it stays cheap on large tables. - Hold the selection in your own state and apply it as an
.in()filter insidefetchRows:
// Set by your multi-select control.let selectedWarehouses: string[] = [];
// Inside fetchRows, after building the base query:if (selectedWarehouses.length > 0) { query = query.in('warehouse', selectedWarehouses);}When the selection changes, refetch from the first page with the plugin’s fetchData({ page: 1 }) (reach it through the wrapper ref’s hotInstance). Because the filter lives in your own state, pagination and sorting preserve it, and the server returns only matching rows.
What you learned
- How
dataProviderreplaces manualafterChangewiring: pagination, sorting, filtering, and CRUD are handled by the plugin once the four async functions are in place. - Why
onRowsCreatemust return the inserted rows, since Handsontable needs the server-assigned primary key to track new rows for later updates and deletes. - How to pass an
AbortSignalto Supabase so stale in-flight requests are canceled when the user changes page, sort, or filter before the response arrives. - How to map Handsontable’s filter conditions to PostgREST methods with a
switchstatement. - Why the built-in
filter_by_valuecheckbox is unavailable server-side: the plugin omits the menu item and ignoresby_valueconditions, because the list could only reflect the loaded page. A custom multi-select backed by aselect distinctquery replaces it. - How Supabase RLS policies are the real access-control layer even though the anon key is public.
Next steps
- Server-side data overview — the full
dataProviderAPI, lifecycle hooks, and options incompatible with server-side mode. - Fetching data — query-parameter structure,
beforeDataProviderFetch, and abort behavior. - CRUD operations — mutation lifecycle, optimistic updates, and programmatic CRUD.
- Supabase Row Level Security — the complete guide to writing RLS policies.
- Supabase PostgREST filters — the full list of
.eq(),.or(),.textSearch(), and other filter methods.