Why another Ruby on Rails admin? We wanted an admin that was:
- Familiar and customizable like Rails scaffolds (less DSL)
- Supports Rails features out of the box (Action Text, Active Storage, Action Mailbox, has_secure_password, etc)
- Built with Hotwire: Turbo, Stimulus and import maps, with no Node or build step
- Search, filters and scopes on index pages, and dark mode
- Works with Propshaft and Sprockets
Add madmin to your application's Gemfile:
bundle add madminThen run the madmin generator:
rails g madmin:installThis will install Madmin and generate resources for each of the models it finds.
Madmin uses Resource classes to add models to the admin area.
To generate a resource for a model, you can run:
rails g madmin:resource ActionText::RichTextIn case of N+1 queries, you can preload the association by overriding the scoped_resource method in the controller:
module Madmin
class PostsController < Madmin::ResourceController
private
def scoped_resources
super.includes(:user)
end
end
endTo expose a resource in the admin without allowing writes, override readonly? in the resource:
class AuditLogResource < Madmin::Resource
def self.readonly?
true
end
endThe new, edit, and delete links are hidden for read-only resources, and any write actions redirect back to the index.
Declare scopes on a resource to render filter buttons on the index page. Each scope must be a scope or class method on the model:
class PostResource < Madmin::Resource
scope :published
scope :draft
endButton labels default to the humanized scope name. To customize or translate them, define an I18n key — madmin.scopes.<param_key>.<scope> for one resource, or madmin.scopes.<scope> shared across resources:
en:
madmin:
scopes:
post:
published: LiveIndex pages have a Filters button for narrowing records by their columns, like "Admin is true" or "Created at after Aug 15". Filters combine with search, scopes and sorting, and they're kept in the URL so filtered lists can be bookmarked and shared.
String, text, number, date, datetime and boolean columns can be filtered. Encrypted columns and attributes that aren't database columns are left out. Turn filtering off for an attribute with filter: false:
class UserResource < Madmin::Resource
attribute :api_token, filter: false
endCustom fields can offer a filter by returning :string, :number, :date, :datetime or :boolean from self.filter_type.
Resource names in the menu, headings and buttons come from the model's translation when it has one, so they follow Rails' own activerecord.models keys:
en:
activerecord:
models:
post:
one: Article
other: ArticlesModels without a translation keep their default names, like Posts and Active Storage / Blobs.
For labels that need logic (e.g. looking up a record name), override scope_label:
class PostResource < Madmin::Resource
def self.scope_label(name)
name.to_s.titleize
end
endThe views packaged within the gem are a great starting point, but inevitably people will need to be able to customize those views.
You can use the included generator to create the appropriate view files, which can then be customized.
For example, running the following will copy over all of the views into your application that will be used for every resource:
rails generate madmin:viewsThe view files that are copied over in this case includes all of the standard Rails action views (index, new, edit, show, and _form), as well as:
application.html.erb(layout file)_javascript.html.erb(default JavaScript setup)_navigation.html.erb(renders the navigation/sidebar menu)
As with the other views, you can specifically run the views generator for only the navigation or application layout views:
rails g madmin:views:navigation
# -> app/views/madmin/_navigation.html.erb
rails g madmin:views:layout # Note the layout generator includes the layout, javascript, and navigation files.
# -> app/views/madmin/application.html.erb
# -> app/views/madmin/_javascript.html.erb
# -> app/views/madmin/_navigation.html.erbIf you only need to customize specific views, you can restrict which views are copied by the generator:
rails g madmin:views:index
# -> app/views/madmin/application/index.html.erbYou might want to make some of your model's attributes visible in some views but invisible in others.
The attribute method in model_resource.rb gives you that flexibility.
# -> app/madmin/resources/book_resource.rbclass BookResource < Madmin::Resource
attribute :id, form: false
attribute :title
attribute :subtitle, index: false
attribute :author
attribute :genre
attribute :pages, show: false
endYou can also scope the copied view(s) to a specific Resource/Model:
rails generate madmin:views:index Book
# -> app/views/madmin/books/index.html.erbYou can set a field type as the second argument. Field types may have additional options to render the field UI.
For example, we can use a select for the genre attribute and specify the collection of options to choose from.
class BookResource < Madmin::Resource
attribute :genre, :select, collection: ["Fiction", "Mystery", "Thriller"]
endUse :nested_has_many or :nested_has_one to edit associated records inside the parent's form. The model needs accepts_nested_attributes_for, with allow_destroy: true for the Remove link to work, and the associated model needs a resource. Use skip to leave out attributes of the associated resource:
class User < ApplicationRecord
has_many :posts
has_one :profile
accepts_nested_attributes_for :posts, :profile, allow_destroy: true
end
class UserResource < Madmin::Resource
attribute :posts, :nested_has_many, skip: %I[user]
attribute :profile, :nested_has_one, skip: %I[user]
endA nested has one shows its fields when the record exists, or an "Add" link when it doesn't, so saving the parent doesn't create an empty record.
File fields upload through the form by default. Add direct_upload: true to upload straight from the browser to your Active Storage service instead, which keeps large files off your app servers and avoids request timeouts:
class PostResource < Madmin::Resource
attribute :image, direct_upload: true
attribute :attachments, direct_upload: true
endCloud services like S3 need CORS configured to accept uploads from the browser. See the Active Storage guide.
You can generate a custom field with:
rails g madmin:field CustomThis will create a CustomField class in app/madmin/fields/custom_field.rb
And the related views:
# -> app/views/madmin/fields/custom_field/_form.html.erb
# -> app/views/madmin/fields/custom_field/_index.html.erb
# -> app/views/madmin/fields/custom_field/_show.html.erbYou can then use this field on our resource:
class PostResource < Madmin::Resource
attribute :title, field: CustomField
endmember_action lets you add custom buttons to a resource's show page. Each block receives the current record and is rendered in the header's .actions div:
class PostResource < Madmin::Resource
member_action do |record|
link_to "Publish", publish_admin_post_path(record), class: "btn btn-secondary"
end
endPass collection: true to also render the action on the index page, in each row next to the built-in "View" and "Edit" links:
class PostResource < Madmin::Resource
member_action collection: true do |record|
link_to "Publish", publish_admin_post_path(record), class: "btn btn-secondary"
end
endcollection_action is the index-page counterpart to member_action. Blocks are rendered in the index header's .actions div, immediately before the built-in "New <Resource>" link.
Unlike member_action, blocks receive no arguments. Each block is instance_exec-ed on the index view context, so link_to, route helpers, policy, current_user, params, resource, and @records are all available. Multiple blocks render in registration order.
Each block is responsible for its own authorization gating — if a button should be hidden for some users, guard it inside the block.
class PostResource < Madmin::Resource
collection_action do
link_to "Bulk Import", bulk_import_admin_posts_path, class: "btn btn-secondary"
end
collection_action do
link_to "Export CSV", export_admin_posts_path(format: :csv), class: "btn btn-secondary"
end
endMadmin runs ActiveSupport load hooks on its core classes so you can extend them from an initializer without reopening or monkey patching them. This is the recommended way for engines and gems to add behavior to Madmin.
| Hook | Base | Use it for |
|---|---|---|
:madmin_resource |
Madmin::Resource |
Adding to the resource DSL |
:madmin_field |
Madmin::Field |
Shared behavior across every field type |
:madmin_search |
Madmin::Search |
Customizing how the search query is built |
:madmin_base_controller |
Madmin::BaseController |
Layouts, helpers, rescue_from, authentication |
:madmin_resource_controller |
Madmin::ResourceController |
CRUD callbacks, scoping records, audit logging |
Blocks are class_eval-ed on the base class, so self is the class itself:
# config/initializers/madmin.rb
ActiveSupport.on_load(:madmin_resource) do
# self == Madmin::Resource, so `extend` adds class-level DSL to every resource
extend MyAdmin::ResourceExtensions
end
ActiveSupport.on_load(:madmin_base_controller) do
# self == Madmin::BaseController
rescue_from Pundit::NotAuthorizedError, with: :admin_not_authorized
endHooks registered after a class has already loaded run immediately, so ordering in your initializers doesn't matter. The controllers live in the engine's app/ directory and are reloadable, which means their hooks re-run on every reload in development — keep the blocks idempotent.
You can use a couple of strategies to authenticate users who are trying to access your madmin panel: Authentication Docs
Madmin follows the system's light or dark setting. To always use one, add a stylesheet with Madmin.stylesheets << "madmin_overrides" (see Assets) and set color-scheme:
:root {
color-scheme: light;
}Madmin's colors are CSS variables on :root, defined with light-dark() so each has a light and a dark value. Custom fields and views can use them to match both modes, and you can override them to change the theme:
:root {
--primary-color: rgb(79 70 229); /* Button backgrounds */
--link-color: light-dark(rgb(79 70 229), rgb(129 140 248));
}The variables are --primary-color, --link-color, --text-color, --light-text-color, --surface-color (page and input backgrounds), --background-color (table headers), --hover-color, --border-color, --input-border-color and --danger-color.
You can customize the JavaScript and CSS assets used by Madmin for your application. To learn how see the Assets Doc
This project uses Standard for formatting Ruby code. Please make sure to run standardrb before submitting pull requests.
The gem is available as open source under the terms of the MIT License.

