docs/content/recipes/data-management/server-side-rails/server-side-rails.md
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
An Order Management grid that:
No local Ruby or Rails installation is required — the backend runs inside Docker.
Add kaminari (pagination) and rack-cors (cross-origin requests) to the Gemfile:
::: example #rb-gemfile
:::
Install them:
bundle install
Why these two gems?
kaminari gives you a .page(n).per(size) query method on any ActiveRecord relation. It also exposes total_count, which you return to Handsontable as totalRows.rack-cors lets 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.Order modelUse 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:decimal
rails db:migrate
The 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:
decimal type stores currency values without floating-point rounding. For production, specify precision and scale: total:decimal{10,2}.id is auto-incremented by the database. It becomes the rowId value on the Handsontable side.created_at is filled automatically by ActiveRecord on insert.The server/order.rb file contains the minimal model with validations and a status enum:
::: example #rb-order
:::
Add realistic seed data in db/seeds.rb:
::: example #rb-seeds
:::
Run it:
rails db:seed
The 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.
Open config/routes.rb and register the orders resource inside an api namespace:
::: example #rb-routes
:::
What's happening:
namespace :api prefixes all routes with /api and scopes the controller under Api::OrdersController. This keeps the API separate from any server-rendered views you might add later.only: [:index] restricts the generated RESTful routes to GET /api/orders -- standard per-resource create/update/destroy routes are replaced by the batch collection routes below.collection do ... end registers three custom routes at the collection URL (/api/orders/...) instead of the detail URL (/api/orders/:id). Handsontable's dataProvider sends 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 |
Create config/initializers/cors.rb:
::: example #rb-cors
:::
What's happening:
insert_before 0 puts the middleware at the very front of the stack so it runs before Rails' routing.origins lists 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.methods must include :options for 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.
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:
# config/initializers/json_case.rb
class ActiveRecord::Base
def as_json(options = {})
super(options).deep_transform_keys { |key| key.to_s.camelize(:lower) }
end
end
Then 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).
Create app/controllers/api/orders_controller.rb:
::: example #rb-controller
:::
This single file implements paginated index, server-side sort and filter, and the three batch CRUD actions.
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 in orders_controller.rb).
index -- paginated list with sort and filterSee the index action in orders_controller.rb.
What's happening:
Order.all. This builds a base ActiveRecord relation without hitting the database yet.apply_filters adds .where(...) clauses from the parsed Handsontable filter list.apply_sort adds an .order(...) clause if the request includes a whitelisted sort_prop.kaminari adds LIMIT and OFFSET via .page(n).per(size). The query is still not executed.as_json triggers the query and serializes results. total_count issues a separate SELECT 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).
See apply_sort in orders_controller.rb.
What's happening:
params[:sort_prop] comes directly from the frontend's sort_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.reorder(prop => order) uses the hash form of .reorder(), which ActiveRecord quotes safely. reorder is used instead of order because the Order model has a default_scope that sorts by created_at DESC — reorder replaces that default, while order would append to it.params[:sort_order] falls back to :asc unless the client explicitly sends desc. This prevents arbitrary SQL fragments (for example, created_at; DROP TABLE orders) from reaching the database.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]=gte
Rails parses bracket-indexed params into a nested hash automatically. See apply_filters and string_col? in orders_controller.rb.
What's happening:
SORTABLE_COLUMNS check is reused as a filter whitelist. Column names that reach the raw SQL fragment (ILIKE, >=, etc.) must be validated against a fixed list.sanitize_sql_like escapes LIKE metacharacters (%, _, \) in the user-supplied value so they are treated as literals, not wildcards.ILIKE is PostgreSQL-specific. On SQLite or MySQL, use LIKE with a COLLATE clause or case-normalize the input.empty/not_empty distinguish between string columns (check for NULL and blank string) and non-string columns (check for NULL only) via the string_col? helper.scope, so multiple filters combine with AND. dataProvider does not send OR groups by default.See create_rows, update_rows, and remove_rows in orders_controller.rb.
What's happening:
create_rows -- receives { rows: [...] }. Each row is inserted with create! inside a transaction, which raises on validation errors and rolls back all inserts if one fails. column_names - ['id', 'created_at', 'updated_at'] blocks the client from setting system-managed fields. Status 201 tells dataProvider the rows were created.update_rows -- receives { rows: [{ id, changes: { ... } }] }. changes.slice(*allowed) (where allowed also excludes id, created_at, updated_at) drops any unknown or system-managed keys so they never reach the ORM. All updates run inside a transaction. Returning the updated rows lets dataProvider reconcile its internal row map.remove_rows -- receives { row_ids: [1, 2, 3] }. delete_all issues a single DELETE ... WHERE id IN (...) statement, which is faster than deleting each row one by one. Status 204 signals a successful delete with no response body.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?
Authorization: Bearer header) 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.
Handsontable's dataProvider calls fetchRows with { page, pageSize, sort, filters }. Map those to the Rails parameter names.
What's happening:
pageSize is converted to page_size because Rails and kaminari use snake_case parameter names.sort is split into two flat params, sort_prop and sort_order. The controller's apply_sort reads them directly.DataProviderFilterColumn[] (each with prop and conditions: [{name, args}]) into one bracket-notation entry per condition. Rails converts the bracket notation to a nested hash automatically.Start the backend and the Vite dev server with bash setup.sh (or make setup), then open http://localhost:5173. The Rails API runs on http://localhost:3000 inside Docker; Vite proxies all /api/* requests to it. The complete frontend code is in the files below.
::: only-for javascript
::: example #javascript-rails --code-only
:::
:::
::: only-for typescript
::: example #typescript-rails --code-only
:::
:::
::: only-for react
::: example #react-rails --code-only
@code
:::
:::
::: only-for angular
::: example #angular-rails --code-only
:::
:::
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. |
{ rowsAmount } in onRowsCreate | dataProvider passes the number of rows to add. The frontend builds default objects and sends them as { rows: [...] }. Returning json.rows lets dataProvider replace client-side placeholder ids with the ids assigned by Rails. |
beforeRowsMutation | Intercepts mutations before they run. Return false to cancel. Used here to show a delete-confirmation notification with Delete/Cancel actions instead of deleting immediately. |
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. |
contextMenu: true | Enables right-click context menu with Insert row above / below and Remove row options. |
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. |
dialog: true | Enables the Dialog plugin used internally by other plugins for confirmation prompts. |
dataProvider calls fetchRows({ page: 1, pageSize: 10 }). Rails returns the first 10 orders and the total row count.columnSorting updates its sort state and dataProvider calls fetchRows again with sort: { prop: 'total', order: 'desc' }. The controller's apply_sort checks SORTABLE_COLUMNS, then issues order(total: :desc).Filters updates its condition list and dataProvider calls fetchRows with the filters array. The controller's apply_filters parses the indexed hash and chains .where calls.dataProvider calls fetchRows({ page: 2, pageSize: 10, ... }). kaminari returns rows 11-20.dataProvider calls onRowsUpdate with [{ id: 7, changes: { total: 142.5 } }]. The frontend maps this to { id, changes } and sends it. update_rows applies the change inside a transaction.dataProvider calls onRowsCreate. create_rows inserts the row and returns it with the database-assigned id. dataProvider updates its row map so subsequent edits target the correct id.dataProvider calls onRowsRemove([3, 7, 14]). remove_rows issues a single DELETE ... WHERE id IN (3, 7, 14).rails new ... --api) skips middleware you do not need for a JSON API and leaves CSRF protection off by default..page(n).per(size) plus .total_count to any ActiveRecord relation -- exactly what you need to build a { rows, total_rows } response.order() or a raw SQL fragment against a fixed whitelist. Never trust params[:sort_prop] or params[:filters] directly.sort: { prop, order } on the frontend to flat sort_prop / sort_order query params. This matches Rails' parameter-naming conventions and keeps the controller focused.DataProviderFilterColumn[] ({prop, conditions: [{name, args}]}) must be flattened into indexed filters[N][prop/condition/value] bracket params before sending. Rails parses the bracket notation into a nested hash automatically — no custom decoder is required on the backend.rack-cors to allow requests from the frontend dev server. Place the middleware before 0 so it runs before Rails' routing.