Server-side data with Ruby on Rails
This tutorial shows how to wire Handsontable’s dataProvider plugin to a Ruby on Rails API-only backend. The backend handles pagination, sorting, and filtering on the server. The frontend displays results and sends every edit back to the API.
Difficulty: Intermediate
Time: ~30 minutes
Backend: Ruby 3.2+, Rails 7.1+, kaminari for pagination, rack-cors for CORS
What you’ll build
An Order Management grid that:
- Loads orders page by page from a Rails API
- Sorts orders by any column on the server
- Filters orders by column value on the server
- Creates, updates, and deletes rows via dedicated collection endpoints
- Handles Rails’ default snake_case payloads without mismatching Handsontable column keys
Before you begin
Install Ruby and Rails (see the Rails installation guide), then generate a new API-only project:
rails new orders-api --apicd orders-apiInstall the JavaScript dependency:
npm install handsontableStep 1 — Add the Ruby gems
Add kaminari (pagination) and rack-cors (cross-origin requests) to the Gemfile:
# Gemfilegem "kaminari"gem "rack-cors"Install them:
bundle installWhy these two gems?
kaminarigives you a.page(n).per(size)query method on any ActiveRecord relation. It also exposestotal_count, which you return to Handsontable astotalRows.rack-corslets the Rails API accept requests from a different origin than the frontend dev server. Without it, the browser blocks the requests before Rails sees them.
Step 2 — Generate the Order model
Use the generator to create the model, the migration, and a matching controller file:
rails generate model Order order_number:string customer:string status:string total:decimalrails db:migrateThe generated migration adds id and created_at / updated_at columns automatically. The final schema has the six fields referenced throughout this recipe: id, order_number, customer, status, total, and created_at.
What’s happening:
- Rails’
decimaltype stores currency values without floating-point rounding. For production, specify precision and scale:total:decimal{10,2}. - The primary key
idis auto-incremented by the database. It becomes therowIdvalue on the Handsontable side. created_atis filled automatically by ActiveRecord on insert.
See server/order.rb for a minimal model with validations and a status enum.
Step 3 — Seed the database
Add realistic seed data in db/seeds.rb (see server/seeds.rb in this recipe), then run:
rails db:seedThe seed script inserts 50 orders across realistic statuses (pending, paid, shipped, delivered, cancelled). It checks whether data already exists, so running it twice does not duplicate rows.
Step 4 — Configure the routes
Open config/routes.rb and register the orders resource inside an api namespace:
Rails.application.routes.draw do namespace :api do resources :orders, only: [:index] do collection do post :create_rows patch :update_rows delete :remove_rows end end endendWhat’s happening:
namespace :apiprefixes all routes with/apiand scopes the controller underApi::OrdersController. This keeps the API separate from any server-rendered views you might add later.only: [:index]restricts the generated RESTful routes toGET /api/orders— standard per-resource create/update/destroy routes are replaced by the batchcollectionroutes below.collection do ... endregisters three custom routes at the collection URL (/api/orders/...) instead of the detail URL (/api/orders/:id). Handsontable’sdataProvidersends every mutation as an array in a single request, so batch endpoints are what you want.
The three resulting routes are:
| Method | URL | Controller action |
|---|---|---|
POST | /api/orders/create_rows | create_rows |
PATCH | /api/orders/update_rows | update_rows |
DELETE | /api/orders/remove_rows | remove_rows |
Step 5 — Configure CORS
Create config/initializers/cors.rb:
Rails.application.config.middleware.insert_before 0, Rack::Cors do allow do origins "http://localhost:5173", "http://localhost:3000"
resource "/api/*", headers: :any, methods: [:get, :post, :patch, :put, :delete, :options], expose: ["Content-Type"] endendWhat’s happening:
insert_before 0puts the middleware at the very front of the stack so it runs before Rails’ routing.originslists the URLs that are allowed to call the API. Replace these with your production domain before deploying.resource "/api/*"scopes the CORS rule to the API endpoints only.methodsmust include:optionsfor browsers to complete preflight requests on non-simple methods (PATCH,DELETE).
Production note: Never use origins "*" in production. Pin the list to the exact domains that own your Handsontable deployment.
Step 6 — Decide on the case convention
Rails emits snake_case JSON by default (order_number, created_at). Handsontable’s column data keys must match whatever the server returns.
You have two practical options:
Option A — Keep snake_case (used in this recipe). Set data: 'order_number' on the matching Handsontable column. No transformation on either side.
Option B — Emit camelCase from Rails. Override serialization globally:
class ActiveRecord::Base def as_json(options = {}) super(options).deep_transform_keys { |key| key.to_s.camelize(:lower) } endendThen use data: 'orderNumber' on the frontend and also translate the sort_prop and filters[][prop] values back to snake_case in the controller.
Why this matters: mixing conventions — Rails returning order_number while Handsontable columns declare data: 'orderNumber' — silently breaks pagination, sorting, and filtering. The grid renders empty cells and the server receives unknown column names. Pick one convention and stick to it.
The rest of this recipe uses Option A (snake_case everywhere).
Step 7 — Build the controller
Create app/controllers/api/orders_controller.rb. This single file implements paginated index, server-side sort and filter, and the three batch CRUD actions.
Whitelist sortable columns
Sort inputs that flow into order() reach the SQL ORDER BY clause. Treating them as raw strings is an SQL-injection risk. Whitelist them once at the top of the class:
SORTABLE_COLUMNS = %w[order_number customer status total created_at].freezeindex — paginated list with sort and filter
def index orders = Order.all orders = apply_filters(orders) orders = apply_sort(orders) orders = orders.page(params[:page]).per(params[:page_size] || 10)
render json: { rows: orders.as_json, total_rows: orders.total_count }endWhat’s happening:
- Start from
Order.all. This builds a base ActiveRecord relation without hitting the database yet. apply_filtersadds.where(...)clauses from the parsed Handsontable filter list.apply_sortadds an.order(...)clause if the request includes a whitelistedsort_prop.kaminariaddsLIMITandOFFSETvia.page(n).per(size). The query is still not executed.as_jsontriggers the query and serializes results.total_countissues a separateSELECT COUNT(*)that Handsontable uses to size the paginator.
The response shape is exactly what dataProvider expects: { rows, total_rows }. The frontend maps total_rows to totalRows (see Step 9).
Sort helper
def apply_sort(scope) prop = params[:sort_prop] order = params[:sort_order] == "desc" ? :desc : :asc
return scope unless SORTABLE_COLUMNS.include?(prop)
scope.order(prop => order)endWhat’s happening:
params[:sort_prop]comes directly from the frontend’ssort_prop=query param (see Step 9).SORTABLE_COLUMNS.include?(prop)is the whitelist check. Any column not on the list is silently ignored — no SQL is generated for it.scope.order(prop => order)uses the hash form of.order(), which ActiveRecord quotes safely.params[:sort_order]falls back to:ascunless the client explicitly sendsdesc. This prevents arbitrary SQL fragments (for example,created_at; DROP TABLE orders) from reaching the database.
Filter helper
Handsontable sends filters as an indexed structure:
?filters[0][prop]=status&filters[0][value]=shipped&filters[0][condition]=eq?filters[1][prop]=total&filters[1][value]=100&filters[1][condition]=gteRails parses bracket-indexed params into a nested hash automatically. Parse each condition and chain .where calls:
def apply_filters(scope) filters = params[:filters] return scope if filters.blank?
Array(filters.values).each do |filter| prop = filter[:prop] value = filter[:value] condition = filter[:condition].presence || "contains"
next unless SORTABLE_COLUMNS.include?(prop)
case condition when "contains" then scope = scope.where("#{prop} ILIKE ?", "%#{value}%") when "not_contains" then scope = scope.where.not("#{prop} ILIKE ?", "%#{value}%") when "eq" then scope = scope.where(prop => value) when "neq" then scope = scope.where.not(prop => value) when "begins_with" then scope = scope.where("#{prop} ILIKE ?", "#{value}%") when "ends_with" then scope = scope.where("#{prop} ILIKE ?", "%#{value}") when "gt" then scope = scope.where("#{prop} > ?", value) when "gte" then scope = scope.where("#{prop} >= ?", value) when "lt" then scope = scope.where("#{prop} < ?", value) when "lte" then scope = scope.where("#{prop} <= ?", value) end end
scopeendWhat’s happening:
- The
SORTABLE_COLUMNScheck is reused as a filter whitelist. Column names that reach the raw SQL fragment (ILIKE,>=, etc.) must be validated against a fixed list. ILIKEis PostgreSQL-specific. On SQLite or MySQL, useLIKEwith aCOLLATEclause or case-normalize the input.- Each condition rebinds
scope, so multiple filters combine withAND.dataProviderdoes not sendORgroups by default.
Batch CRUD actions
def create_rows payload = params.permit!.to_h
rows = Array(payload[:rows]).map do |row| Order.create!(row.slice(*Order.column_names - %w[id created_at updated_at])) end
render json: { rows: rows.as_json }, status: :createdend
def update_rows updated = Array(params[:rows]).map do |row| record = Order.find(row[:id]) changes = row[:changes].to_unsafe_h.slice(*Order.column_names) record.update!(changes) record end
render json: { rows: updated.as_json }end
def remove_rows ids = Array(params[:row_ids]) Order.where(id: ids).delete_all head :no_contentendWhat’s happening:
create_rows— receives{ rows: [...] }. Each row is inserted withcreate!, which raises on validation errors.column_names - ['id', 'created_at', 'updated_at']blocks the client from setting system-managed fields. Status 201 tellsdataProviderthe rows were created.update_rows— receives{ rows: [{ id, changes: { ... } }] }.changes.slice(*Order.column_names)drops any unknown keys so stray fields never reach the ORM. Returning the updated rows letsdataProviderreconcile its internal row map.remove_rows— receives{ row_ids: [1, 2, 3] }.delete_allissues a singleDELETE ... WHERE id IN (...)statement, which is faster than deleting each row one by one. Status 204 signals a successful delete with no response body.
The full controller file lives at server/orders_controller.rb.
Step 8 — CSRF in API mode
ApplicationController in API mode inherits from ActionController::API, which does not include RequestForgeryProtection. That means CSRF verification is off by default for this project, and fetch() requests from the browser do not need an X-CSRF-Token header.
When does CSRF matter?
- If you mount the Rails API under the same origin as a classic Rails app that uses cookie-based sessions, the classic app enforces CSRF protection — your fetch calls will need the token.
- Token-based auth (for example, a
Authorization: Bearerheader) does not require CSRF protection because the browser never attaches the token automatically.
This recipe assumes a stateless API and an Authorization header (or no auth) in production. Add protect_from_forgery with: :null_session inside the controller only if you re-enable session-based auth.
Step 9 — Build the request URL on the frontend
Handsontable’s dataProvider calls fetchRows with { page, pageSize, sort, filters }. Map those to the Rails parameter names:
function buildUrl(base, { page, pageSize, sort, filters }) { const params = new URLSearchParams();
params.set('page', page); params.set('page_size', 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:
pageSizeis converted topage_sizebecause Rails and kaminari use snake_case parameter names.sortis split into two flat params,sort_propandsort_order. The controller’sapply_sortreads them directly.- Each filter condition becomes a
filters[N][prop],filters[N][value],filters[N][condition]triplet. Rails converts the bracket notation to a nested hash automatically.
Step 10 — Initialize Handsontable
Wire everything into dataProvider:
const hot = new Handsontable(container, { dataProvider: { rowId: 'id',
fetchRows: async ({ page, pageSize, sort, filters }, { signal }) => { const url = buildUrl('http://localhost:3000/api/orders', { page, pageSize, sort, filters, }); const res = await fetch(url, { signal });
if (!res.ok) throw new Error(`Fetch failed: ${res.status}`);
const json = await res.json();
// Rails returns snake_case; dataProvider expects camelCase for totalRows. return { rows: json.rows, totalRows: json.total_rows }; },
onRowsCreate: async (rows) => { const res = await fetch('http://localhost:3000/api/orders/create_rows', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rows }), }); const json = await res.json();
return json.rows; // dataProvider updates its row map with server-assigned ids },
onRowsUpdate: async (rows) => { await fetch('http://localhost:3000/api/orders/update_rows', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rows: rows.map((r) => ({ id: r.id, changes: r })) }), }); },
onRowsRemove: async (rowIds) => { await fetch('http://localhost:3000/api/orders/remove_rows', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ row_ids: rowIds }), }); }, },
pagination: { pageSize: 10 }, columnSorting: true, filters: true, dropdownMenu: ['filter_by_condition', 'filter_action_bar'], emptyDataState: true, notification: true,
colHeaders: ['Order #', 'Customer', 'Status', 'Total', 'Created'], columns: [ { data: 'order_number', type: 'text' }, { data: 'customer', type: 'text' }, { data: 'status', type: 'text' }, { data: 'total', type: 'numeric', numericFormat: { pattern: '$0,0.00' } }, { data: 'created_at', type: 'date', dateFormat: 'YYYY-MM-DD', readOnly: true }, ],
rowHeaders: true, licenseKey: 'non-commercial-and-evaluation',});Key options explained:
| Option | What it does |
|---|---|
rowId: 'id' | Tells dataProvider which field uniquely identifies a row. Must match the Rails primary key 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. |
Returning json.rows from onRowsCreate | Lets dataProvider replace client-side placeholder ids with the ids assigned by Rails. |
pagination: { pageSize: 10 } | Enables the pagination toolbar. dataProvider passes the current page and size to fetchRows automatically. |
columnSorting: true | Enables column header click-to-sort. The sort state is passed to fetchRows. |
filters: true with dropdownMenu | Renders the column filter UI. Active conditions are passed to fetchRows. |
emptyDataState: true | Shows a friendly illustration when the API returns zero rows (for example, when a filter matches nothing). |
notification: true | Shows automatic error toasts 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 }). Rails returns the first 10 orders and the total row count. - User clicks a column header:
columnSortingupdates its sort state anddataProvidercallsfetchRowsagain withsort: { prop: 'total', order: 'desc' }. The controller’sapply_sortchecksSORTABLE_COLUMNS, then issuesorder(total: :desc). - User applies a column filter:
Filtersupdates its condition list anddataProvidercallsfetchRowswith thefiltersarray. The controller’sapply_filtersparses the indexed hash and chains.wherecalls. - User navigates to page 2:
dataProvidercallsfetchRows({ page: 2, pageSize: 10, ... }). kaminari returns rows 11-20. - User edits a cell:
dataProvidercollects the changed cells for each row and callsonRowsUpdatewith[{ id: 7, total: 142.5 }]. The frontend wraps each row in{ id, changes }before sending.update_rowsapplies the change. - User adds a row:
dataProvidercallsonRowsCreate.create_rowsinserts the row and returns it with the database-assignedid.dataProviderupdates its row map so subsequent edits target the correct id. - User deletes rows:
dataProvidercallsonRowsRemove([3, 7, 14]).remove_rowsissues a singleDELETE ... WHERE id IN (3, 7, 14).
What you learned
- Rails API mode (
rails new ... --api) skips middleware you do not need for a JSON API and leaves CSRF protection off by default. - kaminari adds
.page(n).per(size)plus.total_countto any ActiveRecord relation — exactly what you need to build a{ rows, total_rows }response. - Validate every column name that reaches
order()or a raw SQL fragment against a fixed whitelist. Never trustparams[:sort_prop]orparams[:filters]directly. - Pick one case convention (snake_case or camelCase) for the whole round trip. Mixing conventions silently breaks pagination, sorting, and filtering.
- Translate
sort: { prop, order }on the frontend to flatsort_prop/sort_orderquery params. This matches Rails’ parameter-naming conventions and keeps the controller focused. - Handsontable’s indexed
filters[N][...]format parses directly into Rails’ nested hash params — no custom decoder is required. - Use
rack-corsto allow requests from the frontend dev server. Place the middleware before0so it runs before Rails’ routing.