Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 075 / 100

Admin panels in Rails: a practical guide for developers

Back offices without starting from scratch.

Expona Admin7 min read

Building Admin Panels in Rails Without Reinventing the Wheel

Every production application needs a back office—a place where your team can manage users, content, orders, and system state without touching the codebase. Building one from scratch costs weeks and introduces security risks. Admin panels in Rails: a practical guide for developers shows you how to leverage existing gems to ship a secure, feature-rich admin interface in days instead.

Why You Need an Admin Panel

A solid admin panel pays dividends across your organization. Your support team resolves customer issues faster. Your operations team exports reports without Slack messages to engineers. Your finance team audits transactions for compliance. Your product team toggles features for testing. Without this layer, every request lands in your engineering queue.

The challenge is that admin interfaces are tedious to build manually—lots of CRUD scaffolding, form validation, filtering, and permissions logic. Rails gives us several mature solutions that handle this boilerplate, letting us focus on domain-specific customizations.

Evaluating Your Options

ActiveAdmin

ActiveAdmin is the oldest and most battle-tested solution. It uses a DSL to define resources declaratively. If you need something quick and your domain is relatively simple, ActiveAdmin shines. The downside is that it has opinions about structure and styling, and customization beyond the DSL can feel clunky.

ruby
ActiveAdmin.register Order do
  permit_params :status, :customer_id
  
  index do
    selectable_column
    id_column
    column :customer
    column :total
    column :created_at
    actions
  end
  
  show do |order|
    attributes_table do
      row :id
      row :customer
      row :total
      row :status
    end
  end
end

Avo

Avo is newer and more Rails-like in its philosophy. It generates clean, modern UI and integrates smoothly with Rails conventions. Avo's interface is intuitive for end users, and its customization hooks are less magical than ActiveAdmin's DSL. It's becoming the default choice for teams building on modern Rails versions.

ruby
class OrderResource < Avo::BaseResource
  self.title = :id
  self.search_query = -> { query.ransack(id_eq: params[:q], m: "or").result(distinct: true) }

  field :id, as: :id
  field :customer, as: :belongs_to
  field :total, as: :number
  field :status, as: :select, options: { pending: "Pending", shipped: "Shipped", delivered: "Delivered" }
  field :created_at, as: :date_time
end

Administrate

Administrate leans into Rails conventions and generates minimal, understandable code. You get real controllers and views you can customize directly. It's ideal if you want full control and don't mind maintaining more boilerplate. Teams that need deep customization often prefer this approach.

Madmin

Madmin is lightweight and serves as a middle ground. It requires less setup than ActiveAdmin but gives you more control than Avo. Use it if your admin surface area is small or if you want a hybrid approach—Madmin for the basics and custom controllers for special cases.

Choosing Between Them

Gem Learning Curve Customization Community Size Best For
ActiveAdmin Medium Medium (DSL-bound) Large Traditional apps, quick wins
Avo Low High (Rails-like) Growing Modern Rails, SaaS products
Administrate Low Very High (controllers) Medium Full control, team familiar with Rails
Madmin Very Low High Small Minimal admin needs, prototyping

For this guide, we'll focus on Avo because it balances ease of use with modern Rails practices, but the authorization and audit patterns apply to all solutions.

Authorization: Restrict Admin Access by Role

Your first rule: not everyone should see the admin panel. An admin gem gives you CRUD for free, but you must add role-based access control (RBAC) immediately.

Avo integrates with Pundit for authorization. Define policies that gate access to resources and actions:

ruby
class OrderPolicy < ApplicationPolicy
  def index?
    user.admin? || user.support_team?
  end

  def show?
    user.admin? || (user.support_team? && user.region == record.region)
  end

  def update?
    user.admin?
  end

  def destroy?
    user.admin? && !record.shipped?
  end
end

In your Avo resource, use the authorize callback:

ruby
class OrderResource < Avo::BaseResource
  def authorize_resource(user, record)
    OrderPolicy.new(user, record)
  end
end

For field-level authorization, restrict visibility:

ruby
class OrderResource < Avo::BaseResource
  field :discount_amount, as: :number do |resource|
    visible = -> { current_user.admin? }
  end
end

Test your policies thoroughly. A bug here exposes sensitive data or allows unintended modifications. Use request specs to verify that users in different roles see and can do exactly what you intend.

Custom Actions: Bulk Operations and Exports

Standard CRUD isn't enough. Your ops team needs to refund multiple orders at once. Your analytics team needs to export user data as CSV. Avo supports custom actions that appear alongside standard operations.

Define a bulk action for refunds:

ruby
class BulkRefundAction < Avo::BaseAction
  self.name = "Bulk Refund"
  self.message = "Refund selected orders?"
  self.confirmation_required = true
  
  def handle(records:, fields:, current_user:)
    records.each do |order|
      order.refund! if order.refundable?
    end
    
    succeed "#{records.count} orders refunded."
  end
end

Register it on the resource:

ruby
class OrderResource < Avo::BaseResource
  action BulkRefundAction, on: :index
end

For exports, add a custom action that generates a CSV:

ruby
class ExportOrdersAction < Avo::BaseAction
  self.name = "Export to CSV"
  self.standalone = true
  
  def handle(records:, fields:, current_user:)
    csv_data = CSV.generate do |csv|
      csv << ["Order ID", "Customer", "Total", "Status"]
      records.each do |order|
        csv << [order.id, order.customer.name, order.total, order.status]
      end
    end
    
    send_data csv_data, filename: "orders-#{Time.current.to_date}.csv"
  end
end

Respect authorization here too. Only users with export permission should see the action, and exported data should respect row-level permissions. If a support agent can only see their region's orders, the export should filter by region automatically.

Audit: Log Admin Changes

Compliance teams and post-incident reviews need a record of who changed what and when. An audit trail protects you legally and helps you debug issues. Don't rely on Rails' default update timestamp alone.

Use a gem like audited to capture every admin change:

ruby
class Order < ApplicationRecord
  audited
end

This logs all attribute changes to an audits table. But that's not enough. You need to know why the change happened. Add a reason field to audit records when changes come from the admin panel:

ruby
class OrdersController < ApplicationController
  def update
    @order = Order.find(params[:id])
    @order.audit_comment = params[:audit_reason]
    @order.update(order_params)
  end
end

If you're using Avo, add an audit trail view that shows what changed:

ruby
class OrderResource < Avo::BaseResource
  panel name: "Audit Trail" do
    component render(AuditTrailComponent.new(resource: _resource))
  end
end

In your AuditTrailComponent, display audits in reverse chronological order:

ruby
class AuditTrailComponent < ViewComponent::Base
  def initialize(resource:)
    @resource = resource
  end
  
  def render?
    @resource.audits.any?
  end
end

Then the template:

ruby
<div>
  <% @resource.audits.reverse.each do |audit| %>
    <div class="audit-entry">
      <strong><%= audit.user.email %></strong>
      <%= audit.action %> at <%= audit.created_at.strftime("%Y-%m-%d %H:%M %Z") %>
      <% if audit.audited_changes.any? %>
        <ul>
          <% audit.audited_changes.each do |field, (before, after)| %>
            <li><%= field %>: <%= before %> → <%= after %></li>
          <% end %>
        </ul>
      <% end %>
    </div>
  <% end %>
</div>

Store audit logs separately if you need them for compliance. Some teams write audits to a dedicated immutable log store or stream them to a SIEM tool. For most applications, the database is fine, but make sure you back it up and restrict who can delete audit records.

Common Mistakes

  • Trusting the admin panel for authentication. Always verify user identity and permissions server-side. Client-side hiding of actions is not security.
  • Skipping authorization on custom actions. A bulk refund action that doesn't check permissions is a compliance nightmare. Every action needs the same policy checks as standard CRUD.
  • Exporting sensitive data without masking. If your export includes customer emails or phone numbers, ensure only authorized users can export and logs record the action.
  • Not testing the admin panel as a user journey. Admins interact with your app differently. Test the full flow: login, navigate, perform an action, verify the audit log.
  • Treating admin panels as low priority. They're internal-facing, yes, but they're often under less scrutiny than public features. That makes them attractive targets. Invest in their security.
  • Hardcoding permissions in the admin gem instead of using a policy layer. This creates technical debt and makes permissions hard to test. Delegate to Pundit or a similar library.

Key Takeaways

  • Admin panels in Rails are solved problems. Use Avo, ActiveAdmin, Administrate, or Madmin rather than building custom CRUD.
  • Authorization is not optional. Layer Pundit or CanCan on top of your chosen gem and test permissions rigorously.
  • Custom actions let you automate common ops workflows—bulk operations, exports, state transitions—but they inherit the same authorization requirements as standard CRUD.
  • Audit logging is essential for compliance and debugging. Capture who changed what, when, and why. Make audit records immutable and searchable.
  • Treat your admin panel with the same security rigor as your public API. It's internal-facing, but it's a high-value target.
  • Keep your admin gem configuration simple and delegate complex logic to policies and services. This keeps the codebase maintainable as your admin surface area grows.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles