React Admin with Ruby on Rails
Introduction
Most database-backed applications need some way to view and manipulate the database. These tools don’t have to be pretty since they’re just for internal operations, but you do need to build something. The easier to use and more detailed your admin app is, the more efficient your back-office operations will be.
There are many premade solutions to this problem. One that Nitid.co uses frequently with our Ruby on Rails applications is called ActiveAdmin, but there’s a newer administration tool called React Admin that we’ve used recently.
Comparing the two, ActiveAdmin is very easy to set up and use, but gets tricky if you want to customize it. React Admin, on the other hand, is written in React, making it extremely easy to customize with new components to do whatever you want. React Admin is ideal if you have a back-office admin tool with more extensive customization needs. Another difference is how they talk to the DB. ActiveAdmin talks to the DB automatically (when using Rails) but React Admin needs to communicate through an API. If you already have an API, that might be fine. Otherwise, you’ll need to create an API specifically for it.
This post demonstrates how to integrate React Admin with Ruby on Rails to create a powerful admin interface. It covers the complete implementation from backend API design to frontend data provider configuration. In particular, it covers: a general-purpose mix-in for providing the API, supporting filtering with Ransack, sorting, pagination, and handling form errors.
The full files are included at the bottom of this post.
Architecture Overview
Rails Backend: A shared Ruby module (CRUDActionsForReactAdmin) provides a standardized REST API with CRUD (create/read/update/delete) endpoints for any model
React Admin Frontend: A custom data provider designed to interface with the Rails REST API
- Server-side validation handled by Rails for create and update actions
- File upload support through FormData
- Clean separation between backend logic and frontend presentation
The Rails REST API
React Admin is backend agnostic. We could choose a variety of API specifications such as REST, GraphQL, or even a custom API. We choose REST as the simplest, requiring no additional ruby dependencies.
This guide does not cover some subtleties but reach out to us for a solution to these issues:
- handling nested attributes for has_one or has_many associations
- handling file attachments for ActiveStorage
The attributes can be customized in inheriting controllers:
Example Controller
class WidgetsController < ApplicationController
include CRUDActionsForReactAdmin
# optionally, define a custom JSON shape. Without this the default #as_json is used
def as_json_for_react_admin(resource)
resource.as_json(only: [:attr1, :attr2], methods: [:some_calculation])
end
end
Mix-in: CRUDActionsForReactAdmin
The default implementation of CRUDActionsForReactAdmin.as_json_for_react_admin converts your
model to JSON but you can override it in your specfic controllers if you wnat to customize
things.
def as_json_for_react_admin(resource)
# override this in the controller if needed
resource.as_json
end
The Rails as_json method works for individual records and for an ActiveRecord::Relation of records.
For custom attributes, override as_json_for_react_admin as shown in the example above.
Here is an example used for the Index (see below), as well as the show, new, and edit endpoints
before_action :set_resource, only: %i[show edit update destroy]
def show
render json: as_json_for_react_admin(@resource)
end
def new
@resource = resource_class.new
render json: as_json_for_react_admin(@resource)
end
def edit
render json: as_json_for_react_admin(@resource)
end
# ...
private
# Infer the resource class from the controller name
def resource_class
controller_name.classify.constantize
end
# Find the resource based on the controller name
def set_resource
@resource = resource_class.find(params[:id])
end
Index
The index creates an instance variable, @resources, an ActiveRecord::Relation, applies the filters, sorting, and pagination, then adds the headers needed by React Admin.
def index
@resources = resource_class
@resources = apply_filters(@resources)
@resources = apply_sorting(@resources)
total_count = @resources.count # get the total count before pagination
@resources = apply_pagination(@resources)
set_content_range_header(@resources, total_count)
render json: as_json_for_react_admin(@resources)
end
Filtering
React Admin Filtering is backend agnostic - it simply passes the filters to the
server under the filter param. The value is a json string that parses to a hash (object)
In Ruby, we use the Ransack gem to execute the search. We need to process
the keys to add a Ransack predicate if needed. This allows our apply_filters
method to handle two situations:
- Custom filters using any arbitrary ransack key, such as
name_cont: 'query' - React Admin’s Reference fields (
ReferenceField,ReferenceManyField, etc.), which by default use the Index endpoint with a filter param with no predicate. The ruby method will convert, eg,id: [1]toid_in: [1], allowing Reference fields to work with Ransack.
def apply_filters(scope)
return scope if params[:filter].blank?
filters =
JSON
.parse(params[:filter])
.to_h do |key, value|
is_an_attribute = resource_class.ransackable_attributes.include?(key)
predicate = value.is_a?(Array) ? "in" : "eq"
complete_filter_key = is_an_attribute ? "#{key}_#{predicate}" : key
[complete_filter_key, value]
end
scope.ransack(filters).result
end
This supports the following Index page
<List
filters={[
<TextInput key='name' source="name_cont" label="Name" alwaysOn />,
<DateInput source="created_at_lteq" label="Created on or before" alwaysOn />,
]}
>
<DataTable>
<DataTable.Col source="title" />
<DataTable.Col label="Topic">
<ReferenceField source="topic_id" reference="topics" />
</DataTable.Col>
</DataTable>
</List>
React Admin will make one Index request for this resource,
Parameters: {"filter" => "{\"name_cont\":\"query\"}"} ...
Then, a subsequent request to the Topics#index to get all the associated topics
Parameters: {"filter" => "{\"id\":[67,100,72]}"}
Ruby will pass the first request through to Ransack unchanged, {"name_cont" => "query"},
and parse the second request by adding the in predicate and send it to Ransack
as {'id_in' => [67,100,72]}
Sorting
Sorting is handled server-side. React Admin sends the sort as a JSON string
that parses to a two-element array.
Parameters: {"sort" => "[\"title_en\",\"DESC\"]" ...}
def apply_sorting(scope)
if params[:sort].present?
field, direction = JSON.parse(params[:sort])
return scope.order(field => direction)
elsif default_sort.present?
return scope.order(default_sort)
end
scope
end
def default_sort
# Optionally define this is the inheriting controller
nil
end
In this implementation, we optionally define a default sort in the server side code, in the individual controller. That way the default sort is applied universally, both in Index pages and Reference fields.
Pagination
React Admin applies pagination using the “range” param, a JSON string that parses to a two element array of numbers, the start and end indices. We fall back to a max of 10,000 records for unpaginated requests.
Parameters: {"range" => "[0,9]" ... }
items 0 through 9 (10 items total)
def apply_pagination(scope)
start_index, end_index = JSON.parse(params[:range] || "[0, 9999]")
scope.offset(start_index).limit(end_index - start_index + 1)
end
Content Range Header
React Admin requires a specific format for the “Content-Range” header:
resource_name start-end/total_count
For example, if you have 100 widgets and are viewing items 10-19, the header would be:
Content-Range: widgets 10-19/100
This allows React Admin to know there are 100 total widgets and the current page shows items 10 through 19.
def set_content_range_header(scope, total_count)
response.set_header(
"Content-Range",
"#{controller_name.pluralize} #{scope.offset_value}-#{total_count == 0 ? 0 : [scope.offset_value + scope.size - 1, total_count - 1].min}/#{total_count}",
)
end
React Admin can display the pagination information:
This pagination control bar appears at the bottom of index pages
Returning Errors
While React Admin provides powerful in-browser validation support in forms, we keep all validations server-side. Ruby on Rails’ model validations are robust and, being closest to the database, are most reliable for preserving data integrity. Also, some validations, such as uniqueness checks, can only be run server-side.
We have to massage the ActiveRecord errors into a very specific format for React Admin.
First, we return HTTP status code 422 (Unprocessable Entity) to indicate
validation errors. Then we use errors_formatted_for_react_admin method to return an object like this:
{
"errors": {
"title_en": "can't be blank",
"root": {
"serverError": "Failed to create assessment"
}
}
}
Here’s how these errors appear in the UI:
The serverError (“failed to update assessment”) will be displayed by React Admin using useNotify and the individual attribute errors (“title_en can’t be blank”) will be displayed on the corresponding inputs
Note that if we are using nested_attributes to save changes to associated records - something React Admin forms handle well - we have to do some extra work to get the errors to appear properly on the offending inputs. This is beyond the scope of this post.
def errors_formatted_for_react_admin(record)
display_name = resource_class.name.underscore.humanize.downcase
base_error = record.errors[:base].to_sentence
base_error_message = base_error.present? ? " (#{base_error})" : nil
server_error_message =
"Failed to #{action_name} #{display_name}#{base_error_message}"
{
errors: {
**resource.errors,
root: {
serverError: server_error_message,
},
},
}
end
Connecting React Admin to your API
We use simpleRestProvider from ‘ra-data-simple-rest’, with a few modifications:
- Add the Rails csrf-token (known to Rails as form_authenticity_token)
- Convert the params object to a FormData object
- Structure the params in the shape that Rails expects
Note that we could have simply made a PUT/POST/PATCH request with a JSON payload, but this would not work if we want to attach files (such as for ActiveStorage). The FormData object will accommodate attached files (not shown in this post).
Summary
This guide demonstrates how to integrate React Admin with Ruby on Rails by creating a reusable CRUDActionsForReactAdmin module that provides standardized REST endpoints for any Rails model. The implementation handles filtering through Ransack (with automatic predicate detection for Reference fields), server-side sorting with optional defaults, pagination with Content-Range headers, and server-side validation with properly formatted error responses. On the frontend, we extend React Admin’s simple REST provider to include CSRF tokens and use FormData for Rails compatibility. This architecture gives you a highly customizable admin interface while keeping all business logic and validation server-side in Rails.
If you need help building or upgrading your Ruby on Rails apps, we at Nitid.co would be delighted to take a look. Please call or email us to chat about your project.
Appendix I — Full Files
dataProvider.ts
import { CreateParams, UpdateParams, DataProvider, fetchUtils } from 'react-admin'
import simpleRestProvider from 'ra-data-simple-rest'
import { isPlainObject } from 'is-plain-object'
import pluralize from 'pluralize-esm'
const isObject = (value: unknown): value is Record<string, unknown> => isPlainObject(value)
const BASE_URL = ''
const getFormAuthenticityToken = () =>
document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
const createHeadersWithCsrfToken = (
formAuthenticityToken: string,
moreHeaders?: fetchUtils.Options['headers']
) =>
new Headers({
Accept: 'application/json',
'X-CSRF-Token': formAuthenticityToken,
...moreHeaders,
})
const appendKeyValueEntryToFormData = (formData: FormData, key: string, value: unknown) => {
if (isObject(value)) {
if (Object.keys(value).length === 0) formData.append(key, '{}')
Object.entries(value).forEach(([nestedKey, nestedValue]) =>
appendKeyValueEntryToFormData(formData, `${key}[${nestedKey}]`, nestedValue)
)
} else if (Array.isArray(value)) {
if (value.length === 0) formData.append(key, '')
else if (value.every((item) => isObject(item)))
value.forEach((item, index) => {
Object.entries(item).forEach(([nestedKey, nestedValue]) =>
appendKeyValueEntryToFormData(formData, `${key}[${index}][${nestedKey}]`, nestedValue)
)
})
else value.forEach((item) => formData.append(`${key}[]`, item))
} else if (value !== undefined) {
formData.append(key, value?.toString() || '')
}
}
const createFormData = (params: CreateParams | UpdateParams, resourceNamePlural: string) => {
const formData = new FormData()
formData.append('authenticity_token', getFormAuthenticityToken())
const { id, ...data } = params.data
if (id) formData.append('id', id)
const resourceName = pluralize.singular(resourceNamePlural)
appendKeyValueEntryToFormData(formData, resourceName, data)
return formData
}
const fetchJsonWithCsrfHeader = (url: string, options: fetchUtils.Options = {}) => {
const headers = createHeadersWithCsrfToken(getFormAuthenticityToken(), options.headers)
return fetchUtils.fetchJson(url, { ...options, headers })
}
const dataProvider: DataProvider = {
...simpleRestProvider(BASE_URL, fetchJsonWithCsrfHeader),
create: async (resource, params) => {
const { json } = await fetchUtils.fetchJson(`${BASE_URL}/${resource}`, {
method: 'POST',
body: createFormData(params, resource),
headers: createHeadersWithCsrfToken(getFormAuthenticityToken()),
})
return { data: json }
},
update: async (resource, params) => {
const { json } = await fetchUtils.fetchJson(`${BASE_URL}/${resource}/${params.id}`, {
method: 'PATCH',
body: createFormData(params, resource),
headers: createHeadersWithCsrfToken(getFormAuthenticityToken()),
})
return { data: json }
},
fetchJson: fetchJsonWithCsrfHeader,
}
export default dataProvider
crud_actions_for_react_admin.rb
module CRUDActionsForReactAdmin
extend ActiveSupport::Concern
included do
before_action :set_resource, only: %i[show edit update destroy]
before_action :expires_now # disable caching for index actions to prevent stale pagination data
end
# CRUD Actions
def index
@resources = resource_class
@resources = apply_filters(@resources)
@resources = apply_sorting(@resources)
total_count = @resources.count # get the total count before pagination
@resources = apply_pagination(@resources)
set_content_range_header(@resources, total_count)
render json: as_json_for_react_admin(@resources)
end
def show
render json: as_json_for_react_admin(@resource)
end
def new
@resource = resource_class.new
render json: as_json_for_react_admin(@resource)
end
def create
@resource = resource_class.new(resource_params)
if @resource.save
render json: as_json_for_react_admin(@resource), status: :created
else
render json: errors_formatted_for_react_admin(@resource),
status: :unprocessable_entity
end
end
def edit
render json: as_json_for_react_admin(@resource)
end
def update
# If type is changing, re-cast the instance before assigning other attrs (for STI)
if resource_params[:type].present?
@resource = @resource.becomes resource_params[:type].constantize
end
if @resource.update(resource_params)
render json: as_json_for_react_admin(@resource)
else
render json: errors_formatted_for_react_admin(@resource),
status: :unprocessable_entity
end
end
def destroy
if @resource.destroy
render json: { id: @resource.id }
else
render json: @resource.errors, status: :unprocessable_entity
end
end
private
# Infer the resource class from the controller name
def resource_class
controller_name.classify.constantize
end
# Find the resource based on the controller name
def set_resource
@resource = resource_class.find(params[:id])
end
def apply_filters(scope)
return scope if params[:filter].blank?
filters =
JSON
.parse(params[:filter])
.to_h do |key, value|
is_an_attribute = resource_class.ransackable_attributes.include?(key)
predicate = value.is_a?(Array) ? "_in" : "_eq"
[is_an_attribute ? "#{key}#{predicate}" : key, value]
end
scope.ransack(filters).result
end
def apply_pagination(scope)
start_index, end_index = JSON.parse(params[:range] || "[0, 9999]")
scope.offset(start_index).limit(end_index - start_index + 1)
end
def default_sort
# Optionally define this is the resource controller
nil
end
def apply_sorting(scope)
if params[:sort].present?
field, direction = JSON.parse(params[:sort].to_s).map(&:underscore)
return scope.order(field => direction)
elsif default_sort.present?
return scope.order(default_sort)
end
scope
end
def set_content_range_header(scope, total_count)
response.set_header(
"Content-Range",
"#{controller_name.pluralize} #{scope.offset_value}-#{total_count == 0 ? 0 : [scope.offset_value + scope.size - 1, total_count - 1].min}/#{total_count}",
)
end
def errors_formatted_for_react_admin(record)
display_name = resource_class.name.underscore.humanize.downcase
base_error = record.errors[:base].to_sentence
base_error_message = base_error.present? ? " (#{base_error})" : nil
server_error_message =
"Failed to #{action_name} #{display_name}#{base_error_message}"
{
errors: {
**record.errors,
root: {
serverError: server_error_message,
},
},
}
end
def as_json_for_react_admin(resource)
# override this in the inheriting controller as needed
resource.as_json
end
# by permitting all attributes, we are assuming any logged in user is an omnipotent admin.
# override this in the inheriting controller as needed
def resource_params
params.require(resource_class.name.underscore.to_sym).permit!
end
end