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.
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
endAvo
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.
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
endAdministrate
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:
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
endIn your Avo resource, use the authorize callback:
class OrderResource < Avo::BaseResource
def authorize_resource(user, record)
OrderPolicy.new(user, record)
end
endFor field-level authorization, restrict visibility:
class OrderResource < Avo::BaseResource
field :discount_amount, as: :number do |resource|
visible = -> { current_user.admin? }
end
endTest 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:
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
endRegister it on the resource:
class OrderResource < Avo::BaseResource
action BulkRefundAction, on: :index
endFor exports, add a custom action that generates a CSV:
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
endRespect 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:
class Order < ApplicationRecord
audited
endThis 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:
class OrdersController < ApplicationController
def update
@order = Order.find(params[:id])
@order.audit_comment = params[:audit_reason]
@order.update(order_params)
end
endIf you're using Avo, add an audit trail view that shows what changed:
class OrderResource < Avo::BaseResource
panel name: "Audit Trail" do
component render(AuditTrailComponent.new(resource: _resource))
end
endIn your AuditTrailComponent, display audits in reverse chronological order:
class AuditTrailComponent < ViewComponent::Base
def initialize(resource:)
@resource = resource
end
def render?
@resource.audits.any?
end
endThen the template:
<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.






Comments
Loading comments…