React Admin with Ruby on Rails

by Nitid Team | November 24, 2025
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] to id_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:

React Admin content-range header UI example 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:

React Admin error display showing field and server errors 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