| name | avo-resources |
| description | Generate an Avo CRUD resource from a Rails model and configure resource-level behavior in `app/avo/resources/<name>.rb` plus its controller. Use when the user wants to generate an Avo resource, add a model to the admin panel, expose a table in the admin, set a resource's title/description/icon/cover/avatar or discreet info, fix ActionDispatch::MissingController, avoid N+1 with includes, tune sort/pagination/index query, build an array (non-DB) resource, or map multiple resources to one model. |
| allowed-tools | Read, Edit, Write, Glob, Grep, Bash, WebFetch |
| metadata | {"requires-gem":"none — Community"} |
Avo Resources
An Avo resource turns one Rails model into a full CRUD admin interface — index, show, new, edit, delete — configured from a single Ruby file at app/avo/resources/<name>.rb. Think of it as the admin-side counterpart to the model: the model defines the data, the resource defines how Avo displays and manages it. Every resource is a class like Avo::Resources::Post < Avo::BaseResource, and each resource must be paired with a controller at app/controllers/avo/<name>s_controller.rb (e.g. Avo::PostsController < Avo::ResourcesController). The generator creates both for you. Resource-level options are class attributes (self.title, self.includes, …) declared at the top of the file; the fields themselves go in def fields, and associations are just fields too — those two are documented in the sibling avo-fields and avo-associations skills, not here.
Docs
Authoritative docs — fetch on demand rather than guessing, and verify every option name against the docs or the app's installed Avo source before writing it:
When this applies
Explicit (Avo named): "generate/create an Avo resource", "add a resource for Order", "configure the Post resource", "set the resource title/description/icon", "add a cover photo / avatar to the resource", "make an array resource", "register two resources for one model", "STI resource".
Implicit (Rails-shaped, no mention of Avo): "add the Invoice model to the admin", "expose the orders table in the admin panel", "show these records in Avo but they don't come from a table", "the admin is showing the wrong resource for User", "I get ActionDispatch::MissingController when I open a resource", "the index is doing N+1 queries", "sort the admin list by position", "let admins look up records by slug instead of id", "the admin count query is too slow on a huge table".
Workflow
1. Generate the resource (and its controller)
For an existing model, generate the resource directly. This also auto-generates the paired controller — the resource generator invokes avo:controller for you:
bin/rails generate avo:resource post
That writes app/avo/resources/post.rb and app/controllers/avo/posts_controller.rb. If the model already has columns and associations, matching field lines are filled in automatically.
Other generation modes:
bin/rails generate avo:resource --model-class post
bin/rails generate avo:all_resources
bin/rails generate avo:resource Galaxy::Planet
bin/rails generate avo:resource Movie --array
When you scaffold the model itself, the Avo resource and controller are generated alongside the standard Rails files:
bin/rails generate model car make:string mileage:integer
bin/rails generate model car make:string mileage:integer --skip-avo-resource
MissingController gotcha: every resource needs its controller. If the controller file is missing (deleted, or the resource was hand-written), opening the resource raises ActionDispatch::MissingController. Generate the missing one:
bin/rails generate avo:controller post
To make generated controllers inherit from a shared base (e.g. one that adds authentication), pass --parent-controller on either generator, or set it once in the initializer:
bin/rails g avo:resource city --parent-controller Avo::BaseResourcesController
Avo.configure do |config|
config.resource_parent_controller = "Avo::BaseResourcesController"
end
2. Fill in fields and associations
The generated file has a def fields block. Adding, ordering, and configuring fields — and association fields like belongs_to/has_many — is the job of the avo-fields and avo-associations skills. Don't re-derive that here; a minimal resource looks like:
class Avo::Resources::Post < Avo::BaseResource
self.includes = []
def fields
field :id, as: :id
field :name, as: :text
field :body, as: :textarea
field :user, as: :belongs_to
end
end
3. Set the record's display identity
Avo guesses a record's display name from name, title, then label, falling back to id. Override with self.title (a Symbol pointing at an attribute/getter, or a block with record/resource):
class Avo::Resources::Comment < Avo::BaseResource
self.title = :slug
self.title = -> { ActionView::Base.full_sanitizer.sanitize(record.body).truncate(30) }
end
Add a message under the resource name with self.description, the sidebar icon with self.icon, and an image with self.avatar (small, on show/forms) or self.cover (banner):
class Avo::Resources::User < Avo::BaseResource
self.description = "These are the users of the app."
self.icon = "tabler/outline/user"
self.avatar = { source: :avatar, visible_on: [:show, :forms] }
self.cover = { source: :cover_photo, size: :md, visible_on: [:show] }
end
self.description is rendered as raw HTML — never feed it user-editable data (stored-XSS risk). A block gets record, resource, view, current_user, params.
self.cover/self.avatar were named cover_photo/profile_photo in Avo 3. A Symbol source: renders nothing for unpersisted (new) records — use a block if you want a placeholder on new/index.
Surface small metadata (timestamps, id, a badge/link) next to the title without spending a field, via self.discreet_information:
class Avo::Resources::Post < Avo::BaseResource
self.discreet_information = [
:timestamps,
{ as: :badge, text: -> { record.published_at ? "Published" : "Draft" } }
]
end
4. Tune index performance and behavior
Eager-load associations and attachments to kill N+1 on the index:
class Avo::Resources::Post < Avo::BaseResource
self.includes = [:user, :tags]
self.attachments = [:cover_photo]
end
Control the default sort, drop a model default_scope on index, or teach Avo to find records by something other than id:
class Avo::Resources::Task < Avo::BaseResource
self.default_sort_column = :position
self.default_sort_direction = :asc
self.index_query = -> { query.unscoped }
end
class Avo::Resources::Post < Avo::BaseResource
self.find_record_method = -> {
id.to_i == 0 ? query.find_by!(slug: id) : query.find(id)
}
end
FriendlyId is detected automatically (no find_record_method needed); prefixed_ids and hashid-rails work out of the box. On huge tables, skip the count with self.pagination = { type: :countless }.
5. Control the save flow and error display
class Avo::Resources::Comment < Avo::BaseResource
self.confirm_on_save = true
self.after_create_path = :index
self.after_update_path = :edit
end
Validation and errors need no Avo config — Avo runs your model's validations on every write. Anything you add via errors.add stops the action and shows the message:
errors.add(:age, "must be over 18.") → inline under the age field.
errors.add(:base, "…") (or an error whose attribute has no field on the form) → alert banner at the top.
- Non-validation exceptions during save/destroy (FK constraint on delete,
after_save failure) are caught, added as a :base alert, and shown gracefully instead of 500-ing. Developers additionally see the backtrace (gated on Avo::Current.user_is_developer?).
6. Array (non-database) resources
For structured data that isn't backed by a table, generate with --array. The class extends Avo::Resources::ArrayResource and returns data from records (array of hashes, AR objects, an ActiveRecord::Relation, or StoreModel instances):
class Avo::Resources::Movie < Avo::Resources::ArrayResource
def records
[
{ id: 1, name: "The Shawshank Redemption", release_date: "1994-09-23" },
{ id: 2, name: "The Godfather", release_date: "1972-03-24" }
]
end
def fields
field :id, as: :id
field :name, as: :text
field :release_date, as: :date
end
end
Array resources are Beta: sorting is not supported, and the array is rebuilt on every request (cache inside records if it gets heavy). For external-API-backed data, prefer an HTTP Resource.
7. Sidebar, shortcuts, external links, and multiple resources per model
class Avo::Resources::TeamMembership < Avo::BaseResource
self.visible_on_sidebar = false
self.hotkey = "g m"
self.external_link = -> { main_app.team_membership_path(record) }
end
When two resources map to the same model, Avo picks one alphabetically wherever it needs a default (associations, links) — often the wrong one. Pin the default with model_resource_mapping, and point specific associations elsewhere with use_resource (an avo-associations concern):
Avo.configure do |config|
config.model_resource_mapping = { "User": "Avo::Resources::User" }
end
For STI, send index clicks to the child record with self.link_to_child_resource = true on the parent resource; set self.model_class on any resource whose model can't be inferred from the class name.
Key options
| Option | Does | Tiny example |
|---|
self.title | Record display name | self.title = :name |
self.description | Message under the name (raw HTML!) | self.description = "App users." |
self.icon | Sidebar icon | self.icon = "tabler/outline/user" |
self.avatar / self.cover | Small photo / banner | self.cover = { source: :cover_photo, size: :md } |
self.discreet_information | Metadata by the title | self.discreet_information = :timestamps |
self.model_class | Model when not inferable / secondary resource | self.model_class = "Delayed::Job" |
self.includes / self.attachments | Eager-load on Index (N+1) | self.includes = [:user, :tags] |
self.default_sort_column / _direction | Index sort | self.default_sort_column = :position |
self.index_query | Base Index query (drop default_scope) | self.index_query = -> { query.unscoped } |
self.find_record_method | Lookup by slug/custom id | -> { query.find_by!(slug: id) } |
self.pagination | Skip count on big tables | self.pagination = { type: :countless } |
self.confirm_on_save | Confirm dialog before save | self.confirm_on_save = true |
self.after_create_path / _update_path | Post-save redirect | self.after_create_path = :index |
self.visible_on_sidebar / |
Search (self.search), grid/map view types, record reordering, and i18n live on their own docs pages — reach for the docs map when a request touches those.
Gotchas
- Every resource needs a controller. A missing controller →
ActionDispatch::MissingController on open. Generate it with bin/rails g avo:controller <name>. The resource generator does this automatically; hand-written resources don't.
self.description is raw HTML. Never interpolate user-editable content into it — stored XSS. Same care with title: in discreet-information tooltips (sanitize HTML there).
- Cover/avatar renamed in Avo 4. It's
self.cover / self.avatar now, not cover_photo / profile_photo. A Symbol source: shows nothing for unpersisted records — use a block for a placeholder.
- Two resources, one model → wrong one wins. Avo resolves the default alphabetically. Set
config.model_resource_mapping and/or use_resource: on associations.
- Secondary / namespaced / oddly-named resources need
self.model_class (or the matching namespace) or Avo can't infer the model. Namespaced resources whose namespace matches the model's namespace infer automatically.
- Array resources are Beta: no sorting, and
records re-runs every request. Cache inside records for large sets, or move to an HTTP Resource.
find_record_method in batch contexts: id arrives as an Array for bulk actions — return a collection (query.where(...)) in that branch, not a single record.
visible_on_sidebar only affects the auto-generated menu. If the app uses the menu editor, control visibility in its visible block instead.
- Don't re-invent fields/associations here. Field DSL is the avo-fields skill;
belongs_to/has_many/use_resource is avo-associations.
- Verify before writing. Option names drift between versions — check the docs URLs above or the app's installed Avo source rather than trusting memory.
Report
When done, tell the user:
- Which resource file(s) and controller file(s) you created or edited (full paths), and the generator command(s) run.
- The model each resource maps to, and any
self.model_class / model_resource_mapping you set to disambiguate.
- The resource-level options you configured (title, includes, sort, pagination, etc.) and why.
- Anything still needed for the resource to work: run pending migrations, generate a missing controller, define fields (avo-fields) or associations (avo-associations), or add a policy if authorization is enabled.
- Note when a resource is an array/Beta resource or has multiple resources per model, so the user knows the limitations.