Skip to content

Upgrade guide

Stay up to date

The up-to-date status of all the gems is available at github.com/avo-hq/avo/issues/4349

The upgrade process from Avo 3 to Avo 4 contains several important improvements and changes.

We've made these changes to improve consistency and usability of the API and we've added some new features. Here's what you need to know to upgrade your Avo 3 application to Avo 4.

Take these steps one by one in order to upgrade your app. You can follow it yourself or let your LLM do the heavy lifting:

Upgrading using a coding agent

Upgrade this Avo 3 app to Avo 4 using https://docs.avohq.io/4.0/avo-3-avo-4-upgrade.html as the source of truth. You may run shell commands (grep, bundle, the test suite, git) without asking each time — but ask before anything destructive or ambiguous.

Setup
- Confirm all incremental Avo 3 upgrades are applied up to the current version (https://docs.avohq.io/3.0/avo-2-avo-3-upgrade.html). If not, stop and tell me.
- Create a new branch. Run the test suite to capture a baseline; if it's already red, stop and tell me.
- Commit after each chapter, with the chapter name in the message.

Inventory before editing
- For each chapter, grep the codebase for the APIs it touches BEFORE changing anything (e.g. main_panel, no_confirmation, cluster/row, profile_photo, cover_photo, branding, with_tools, result_path, params[:via_association], `size:` in pagination, PanelComponent, the renamed view-type components, etc.).
- Many chapters won't apply. Mark each APPLIES / NOT USED / NEEDS REVIEW. Never apply a change for an API the app doesn't use.

Gems first
- Update the Gemfile to >= 4.0.0 for `avo` and every `avo-*` gem in use (check for avo-nested, avo-rhino_field, avo-dynamic_filters, etc.), move private gems under the packager.dev source block, run bundle, and boot the app before touching app code.
- Nested forms now need the separate avo-nested gem — add it if has_many/has_one/habtm use `nested`.

Apply, per chapter
- Make the change, boot the app, re-run tests. Prefer Rails route helpers over hardcoded paths.
- When a chapter links a sub-page (appearance, global search, badge, etc.), fetch and follow it rather than guessing the new API.

⚠️ Silent behavior changes — these pass tests but change runtime behavior, flag each explicitly:
- `explicit_authorization` now defaults to true → actions/fields/records with a missing policy method are now DENIED. Audit policies.
- Action `no_confirmation` → `confirmation`, default flipped (modal now shows by default).
- `params[:via_association]` is gone → any `== 'has_many'` branch silently falls through to else. Migrate to `search_type`.
- Dynamic filters `always_expanded` now defaults to true.

Output
- Produce avo-3-to-4-upgrade.md: one checklist item per chapter with status (applied / skipped / needs-review) and what changed.
- End with "Manual verification needed" — things tests can't catch: missing/exploded icons (Heroicons→Tabler), avatar/cover rendering, custom CSS referencing old --avo-* variables or Algolia .aa-* selectors, appearance/branding visuals.

Don't invent APIs — if the guide doesn't cover a case, stop and ask.

Depending on how you use Avo you might not need to do all the steps.

Get started with Avo 4

Beta access and private gems

During the Avo 4 open beta, you can try all Avo gems (including Pro, Advanced, and other private gems) regardless of your subscription tier, including on Community. See Avo 4 status and feedback #4349 for the latest. Once Avo 4 pricing is finalized, you will need an appropriate paid license to keep using paid gems.

Private beta gems are still served from packager.dev. After you enroll at avohq.io/try-4, your licenses (including Community) include a Gem Server Token on your license page. Configure Bundler with that token so bundle install can download private gems. Follow Gem server authentication.

Assuming you are upgrading your Avo 3 app, you need to do three things:

  1. Enroll to the Avo 4 beta program by going to avohq.io/try-4.

  2. Upgrade your Avo gems

  3. Use this guide to upgrade your app to Avo 4.

Upgrade your gems

This means updating your Gemfile to target the Avo 4 version and running the bundle update on the gems you are using avo, avo-pro, avo-advanced, and all other avo gems you are using to use a version greater than or equal to 4.0.0. See what other gems you might have such as avo-nested, avo-rhino_field, etc. because they need to be updated too.

ruby
# Gemfile

# Before
gem "avo"
gem "avo-advanced", source: "https://packager.dev/avo-hq/"

# After
gem "avo", ">= 4.0.0"

# remove avo-pro and avo-advanced from the Gemfile
gem "avo-pro", source: "https://packager.dev/avo-hq/"
gem "avo-advanced", source: "https://packager.dev/avo-hq/"

source "https://packager.dev/avo-hq/" do
  # all or some of these
  gem "avo-authorization", ">= 4.0.0"
  gem "avo-advanced_search", ">= 4.0.0"
  gem "avo-advanced_file_uploads", ">= 4.0.0"
  gem "avo-record_reordering", ">= 4.0.0"
  gem "avo-menu_editor", ">= 4.0.0"
  gem "avo-menu", ">= 4.0.0"
  gem "avo-dashboards", ">= 4.0.0"
  gem "avo-http_resource", ">= 4.0.0"
  gem "avo-dynamic_filters", ">= 4.0.0"
  gem "avo-nested", ">= 4.0.0"
  gem "avo-collaboration", ">= 4.0.0"
  gem "avo-forms", ">= 4.0.0"
  gem "avo-kanban", ">= 4.0.0"
  gem "avo-api", ">= 4.0.0"
  gem "avo-http_resource", ">= 4.0.0"
  gem "avo-reactive_fields", ">= 4.0.0"
  gem "avo-notifications", ">= 4.0.0"
end

# other gems
gem "avo-rhino_field", ">= 0.5.1"
gem "db_config"
bash
bundle install
# this command will update all your Avo gems to the latest version
bin/rails avo:update

INFO

You can check each gem version on avohq.io/gems.

Icons

We started using the Tabler icons instead of the Heroicons. They are provided by the avo-icons gem and you can quickly search for them using the tabler icon search.

Try to use the Tabler icons instead of the Heroicons moving forward.

If you used any of our icons (eg: avo/resources), you should update them to use the new Tabler icons. Check this PR with changes in the icons: https://github.com/avo-hq/avo/pull/4342/changes

If you see some areas which look "exploded" in the app, it's because some icons are missing and you should update them.

Avatars and initials

Avo now uses the avatar and initials of a record or resource throughout the app.

You set the avatar using the avatar configuration (ex-profile photo). The avatar will be used by Avo in multiple places in the app like the Show and Edit views, and the new breadcrumbs. In addition you can use the avatar field to display the avatar in the Index view.

Avo 4 introduces significant improvements to the search functionality, with enhanced resource search and global search capabilities.

Resource search functionality has been significantly enhanced in Avo 4. The most notable improvement is that resource search now updates the current view (table, grid, map, or any other view type) to show only the relevant search results, providing a more intuitive and seamless search experience.

Previously, search results were displayed separately from the main view. Now, when you perform a search on a resource index page, the current view dynamically updates to display only the records that match your search criteria, maintaining the same view format you were using (whether table, grid, map, etc.).

Global search has undergone a major architectural change in Avo 4. The previous implementation using a customized Algolia autocomplete plugin has been completely replaced with a new, fully owned component powered by Hotwire.

This transition brings several significant benefits:

Fully owned component

  • Complete control: Avo now has full development control over the search experience
  • No external dependencies: No longer relies on Algolia's autocomplete plugin
  • Future enhancements: Much greater possibility for future improvements and customizations
  • Consistent experience: Better integration with Avo's overall design system

Enhanced navigation and keyboard shortcuts

The new search component includes improved keyboard navigation:

  • Ctrl + K or Cmd + K - Open global search
  • Up and Down arrow keys - Navigate through search results
  • Enter - Visit the selected record
  • Esc - Close the search modal

New "Show all results" functionality

The global search now includes a comprehensive results page:

  • Quick results: The search dropdown shows a limited number of results (respecting the configured limit)
  • Show all results page: A dedicated page that displays all matching results without the limit restriction
  • Seamless transition: Easy access from the search dropdown to view comprehensive results

Breaking changes and migration notes

Search result limits simplified

self.search[:results_count] has been removed to reduce Avo-specific DSL where plain Rails already covers the need — use .limit() on the relation inside your query: proc instead of a separate results-count option.

Use one of these instead:

  • Global default: config.search_results_count = 16 in config/initializers/avo.rb
  • Per-resource: .limit(N) inside the query: proc

A user-applied .limit() on a relation always takes precedence over the global default. Custom search providers that return an Array are never auto-capped — slice with .first(N) in your proc if needed.


Avo Pro mount point removal

To provide a cleaner public URL for the search page, Avo::Pro is no longer mounted under the avo-pro path prefix.

  • Previous mount point: .../avo-pro/...
  • New mount point: no prefix (mounted at the Avo engine root)

Most Avo::Pro generated links were for internal requests (such as reordering) and were not user-visible. With the introduction of the dedicated search page, the public path became visible, so we removed the avo-pro prefix to be able to use /admin/search?q=da as the public search page instead of /admin/avo-pro/search?q=da.

If you have hardcoded links that include the avo-pro prefix, update them to the new path or, preferably, use Rails route helpers going forward.

This is not breaking unless you used hardcoded URLs, if you used Rails path helpers, no action is needed.

Removed disabled_features configuration

The disabled_features configuration has been removed. It was previously used only for toggling the global search. Replace any usage with the new global_search configuration.

ruby
# Before
Avo.configure do |config|
  config.disabled_features = [:global_search]
end

# After
Avo.configure do |config|
  config.global_search = {
    enabled: false,
  }
end

Check the global search configuration for more information.

Removed help option

The help option in the search configuration is now obsolete and has been removed. If you were using this option in your search configuration, you should remove it:

ruby
class Avo::Resources::User < Avo::BaseResource
  self.search = {
    query: -> { query.ransack(name_cont: q, email_cont: q, m: "or").result(distinct: false) },
    help: -> { "Search by name or email address" } 
  }
end
Repositioned result_path option

The result_path option has been moved to the item configuration and renamed to path.

ruby
class Avo::Resources::User < Avo::BaseResource
  self.search = {
    query: -> { query.ransack(name_cont: q, email_cont: q, m: "or").result(distinct: false) },
    item: -> do
      {
        title: record.name,
        description: record.email,
        image_url: record.avatar.attached? ? main_app.url_for(record.avatar) : nil,
        image_format: :rounded,
        path: avo.resources_user_path(record) 
      }
    end,
    result_path: -> { avo.resources_user_path(record) } 
  }
end
Use search_type instead of params[:global] / params[:via_association]

Avo 4 injects a search_type local into every self.search[:query] proc, with one of four values depending on which surface triggered the search:

search_typeSurfacev3 detection
:globalNavbar ⌘K paletteparams[:global]
:resourceResource-index search barFalsey params[:global] (no via_association)
:associationSearchable association pickerparams[:via_association] == 'has_many'
:kanbanKanban board "add a card" pickerparams[:for_kanban_board] == '1'

params[:via_association] has been removed. The v4 picker no longer sets it, which means any if params[:via_association] == 'has_many' branch will silently fall through to else. There's no error, just the wrong scope applied. This branch must be migrated.

params[:for_kanban_board] has also been removed. The kanban picker no longer sets it, so any if params[:for_kanban_board] == '1' branch will silently fall through to else. Migrate it to search_type == :kanban.

params[:global] still works but is superseded. It can't distinguish :resource from :association, so search_type is the preferred option going forward.

ruby
self.search = {
  query: -> {
    if params[:global]                            
    if search_type == :global
      query.ransack(id_eq: q, m: "or").result(distinct: false)
    elsif params[:via_association] == 'has_many'
    elsif search_type == :association
      query.ransack(name_cont: q).result.order(name: :asc)
    elsif params[:for_kanban_board] == '1'
    elsif search_type == :kanban
      query.where(active: true).ransack(name_cont: q).result
    else # :resource
      query.ransack(id_eq: q, details_cont: q, m: "or").result(distinct: false)
    end
  }
}

Actions

Confirmation option renamed

  • What changed: The no_confirmation action option was renamed to confirmation and the default behavior flipped.
    • Avo 3: no_confirmation (default: false), set to true to skip the confirmation modal.
    • Avo 4: confirmation (default: true), set to false to skip the confirmation modal.

Static configuration

ruby
# Avo 3
class Avo::Actions::ExportCsv < Avo::BaseAction
  self.no_confirmation = true
end

# Avo 4
class Avo::Actions::ExportCsv < Avo::BaseAction
  self.confirmation = false
end

Dynamic configuration (lambda)

ruby
# Avo 3
self.no_confirmation = -> { arguments[:no_confirmation] || false }

# Avo 4
self.confirmation = -> { arguments.key?(:confirmation) ? arguments[:confirmation] : true }

If you customized the actions modal/view

  • Data attribute: data-action-no-confirmation-valuedata-action-confirmation-value
  • Stimulus value: noConfirmationconfirmation
  • Behavior: show the modal when confirmation is true, submit immediately when confirmation is false.

Layout

main_panel is obsolete

The main_panel DSL has been removed in Avo 4. Previously, main_panel was responsible for holding the header component (title, description, controls, etc.) along with your fields.

Te migration depends on your current setup:

main_panel is the first panel and has no sidebar

Replace main_panel directly with card:

ruby
# app/avo/resources/user.rb
class Avo::Resources::User < Avo::BaseResource
  def fields
    main_panel do
    card do
      field :id, as: :id
      field :name, as: :text
    end
  end
end

main_panel is the first panel and has a sidebar

Replace main_panel with panel and wrap the fields (outside the sidebar) with card:

ruby
# app/avo/resources/user.rb
class Avo::Resources::User < Avo::BaseResource
  def fields
    main_panel do
    panel do
      card do
        field :id, as: :id
        field :id, as: :id
      end

      sidebar do
        field :created_at, as: :date_time
      end
    end
  end

Content above main_panel

If you have content above main_panel, add an explicit header before the renamed main_panel:

ruby
# app/avo/resources/user.rb
class Avo::Resources::User < Avo::BaseResource
  def fields
    panel do
      field :status, as: :badge
    end

    header 

    main_panel do
    card do
      field :id, as: :id
      field :name, as: :text
    end
  end
end

Renamed Avo::PanelComponent to Avo::UI::PanelComponent

If you used the panel component in custom HTML partials you should update the import to use the new name. The Avo::PanelComponent has been renamed to Avo::UI::PanelComponent.

erb
<%= render Avo::PanelComponent.new(title: "User information") do |c| %> 
<%= render Avo::UI::PanelComponent.new(title: "User information") do |c| %> 
  <% c.with_body do %>
    <%= render Avo::Fields::IdField.new(record: record) %>
    <%= render Avo::Fields::TextField.new(record: record, field: :name) %>
  <% end %>
<% end %>
PanelComponent does not automatically render the content inside a card

The PanelComponent does not automatically render the content inside a card. Choose the slot based on whether you render your own card:

  • with_card — the panel adds a card (border, background, padding) around your content. Use it when you don't render your own card.
  • with_body — no card, just your content. Use it when your content already includes its own card, otherwise you get a card inside a card.
erb
<%= render Avo::PanelComponent.new(title: "User information") do |c| %> 
<%= render Avo::UI::PanelComponent.new(title: "User information") do |c| %> 
  <% c.with_body do %> 
  <% c.with_card do %> 
    <%= render Avo::Fields::IdField.new(record: record) %>
    <%= render Avo::Fields::TextField.new(record: record, field: :name) %>
  <% end %>
<% end %>
Always wrap a list of fields in ui.description_list

When the content is a list of fields, wrap it in ui.description_list (the Avo::DescriptionListComponent Avo uses internally). This is required — the with_card slot is a flex column, so fields dropped straight into it shrink to half their width and lose their dividers. ui.description_list makes them full-width and adds the dividers.

erb
<%= render Avo::UI::PanelComponent.new(title: "User information") do |c| %>
  <% c.with_card do %>
    <%= render ui.description_list do %> 
      <%= render Avo::Fields::IdField.new(record: record) %>
      <%= render Avo::Fields::TextField.new(record: record, field: :name) %>
    <% end %> 
  <% end %>
<% end %>

<!-- or using the `.description-list` class -->


<%= render Avo::UI::PanelComponent.new(title: "User information") do |c| %>
  <% c.with_card do %>
    <div class="description-list"> 
      <%= render Avo::Fields::IdField.new(record: record) %>
      <%= render Avo::Fields::TextField.new(record: record, field: :name) %>
    </div> 
  <% end %>
<% end %>

Removed the field_container view helper

The field_container helper has been removed. It grouped fields in custom tool and partial views. Replace every use with ui.description_list — the v4 component for the same job (see the section above for why a plain <div> is not a safe replacement).

erb
<% c.with_card do %>
  <%= field_container do %> 
  <%= render ui.description_list do %> 
    <%= avo_edit_field :name, as: :text, form: form %>
    <%= avo_edit_field :population, as: :number, form: form %>
  <% end %>
<% end %>

Renamed with_tools slot to with_controls

The with_tools slot has been renamed to with_controls.

erb
<%= render Avo::UI::PanelComponent.new(title: "User information") do |c| %>
  <% c.with_tools do %> 
  <% c.with_controls do %> 
    <%= render Avo::Fields::IdField.new(record: record) %>
    <%= render Avo::Fields::TextField.new(record: record, field: :name) %>
  <% end %>
<% end %>

panel title in keyword arguments

The panel title is now given as a keyword argument to the panel method.

ruby
# before
panel "User information" do
  field :id, as: :id
  field :name, as: :text
end

# after
panel title: "User information" do
  field :id, as: :id
  field :name, as: :text
end

tab title in keyword arguments

The tab now takes a title keyword argument instead of the first positional argument.

ruby
tab "User information" do
tab title: "User information" do
  panel do
    field :id, as: :id
    field :name, as: :text
  end
end

Branding renamed to Appearance

config.branding has been replaced with config.appearance. The configuration key was renamed, the colors: hash was removed, and the CSS custom properties were renamed. A number of new options (color scheme switching, accent/neutral pickers, database persistence, dark-mode assets) come with it — see the Appearance documentation for the full API.

If you didn't customize config.branding, no action is required.

Rename the configuration key

ruby
config.branding = {     
config.appearance = {   
  logo: "my_company/logo.png",
  logomark: "my_company/logomark.png",
  favicon: "my_company/favicon.ico",
  placeholder: "my_company/placeholder.svg",
  chart_colors: ["#0B8AE2", "#34C683"]
}

logo, logomark, favicon, placeholder and chart_colors behave the same.

colors: hash removed

The flat colors: hash is gone. The palette is now split into independent neutral and accent surfaces — each set via a preset symbol or a full custom palette. See Neutral palette and Accent palette.

Most apps that used colors: were only tinting the primary accent — the simplest replacement is to pick a preset accent:

ruby
config.branding = {
  colors: {                     
    100 => "#CEE7F8",           
    400 => "#399EE5",           
    500 => "#0886DE",           
    600 => "#066BB2"
  }                             
}

config.appearance = {
  accent: :blue
}

Bringing your exact colors back

If you want the same hex values you had in colors:, configure accent_colors: instead. The three-token shape replaces the flat shade hash, and a single palette covers both light and dark mode:

ruby
config.appearance = {
  accent: :brand,
  accent_colors: {
    color:      "#0886DE", # main accent — was the old `500`
    content:    "#066BB2", # subtle/hover — was the old `600`
    foreground: "#FFFFFF"  # text on accent backgrounds
  }
}

The old background: value (page background) is now driven by neutral_colors: instead — set the full 12-shade palette via Custom neutral palette if you need that level of control.

See the Appearance documentation for the full API.

CSS custom properties renamed

If you wrote custom CSS that referenced Avo's brand variables, update the names:

Avo 3Avo 4
--avo-color-application-background--color-background
--avo-color-primary-100--color-avo-neutral-100
--avo-color-primary-400--color-avo-neutral-400
--avo-color-primary-500--color-avo-neutral-500
--avo-color-primary-600--color-avo-neutral-600

Values are no longer RGB triplets — they are full CSS colors (oklch(...), #hex, rgb(...), hsl(...)). Avo 4 also introduces additional design tokens beyond the ones listed above (--color-foreground, --color-primary, --color-secondary, --color-tertiary, --color-content, --color-content-secondary, --color-accent, --color-accent-content, --color-accent-foreground) — inspect Avo's variables.css for the full set.

Components

Renamed view type components

Several view type components have been renamed and moved from the Avo::Index namespace to Avo::ViewTypes:

Avo 3Avo 4
Avo::Index::ResourceMapComponentAvo::ViewTypes::MapComponent
Avo::Index::ResourceTableComponentAvo::ViewTypes::TableComponent

If you're using self.components in your resources to customize these components, update the keys accordingly:

ruby
# app/avo/resources/user.rb
class Avo::Resources::User < Avo::BaseResource
  self.components = {
    "Avo::Index::ResourceMapComponent": "Avo::Custom::ResourceMapComponent", 
    "Avo::Index::ResourceTableComponent": "Avo::Custom::ResourceTableComponent", 
    "Avo::ViewTypes::MapComponent": "Avo::Custom::ResourceMapComponent", 
    "Avo::ViewTypes::TableComponent": "Avo::Custom::ResourceTableComponent", 
  }
end

avo-pro and avo-advanced split into feature gems

In Avo 3, Pro and Advanced features were installed via avo-pro and avo-advanced. In Avo 4, each feature is its own gem in your Gemfile. Both avo-pro and avo-advanced are being phased out.

In Avo 3, avo-advanced depended on avo-pro. If your Gemfile only listed avo-advanced, you still had dashboards, menu, search, and the other Pro features — they came in through that dependency. Replacing avo-advanced in Avo 4 means adding the Pro feature gems too, not only the Advanced ones.

Add only the gems for the features you use.

Pro features

FeatureGem
Dashboardsavo-dashboards
Menu editoravo-menu
Global search, searchable associationsavo-advanced_search
Authorizationavo-authorization
Record reorderingavo-record_reordering

If your Gemfile had avo-pro, remove it and add the gems for the features you use:

ruby
source "https://packager.dev/avo-hq/" do
  gem "avo-pro", ">= 4.0.0.beta"
  gem "avo-dashboards", ">= 4.0.0.beta"
  gem "avo-menu", ">= 4.0.0.beta"
  gem "avo-advanced_search", ">= 4.0.0.beta"
  gem "avo-authorization", ">= 4.0.0.beta"
  gem "avo-record_reordering", ">= 4.0.0.beta"
end

If you use global search and have hardcoded search URLs, see Avo Pro mount point removal above.

Advanced features

FeatureGem
Resource scopesavo-scopes
Customizable controlsavo-custom_controls
Dynamic filtersavo-dynamic_filters
Nested association formsavo-nested

If your Gemfile had avo-advanced, remove it and add the Pro and Advanced gems for the features you use:

ruby
source "https://packager.dev/avo-hq/" do
  gem "avo-advanced", ">= 4.0.0.beta"
  gem "avo-dashboards", ">= 4.0.0.beta"
  gem "avo-menu", ">= 4.0.0.beta"
  gem "avo-advanced_search", ">= 4.0.0.beta"
  gem "avo-authorization", ">= 4.0.0.beta"
  gem "avo-record_reordering", ">= 4.0.0.beta"
  gem "avo-scopes", ">= 4.0.0.beta"
  gem "avo-custom_controls", ">= 4.0.0.beta"
  gem "avo-dynamic_filters", ">= 4.0.0.beta"
  gem "avo-nested", ">= 4.0.0.beta"
end

Resource scopes

Every scope class under app/avo/scopes/ must inherit from Avo::Scopes::BaseScope instead of Avo::Advanced::Scopes::BaseScope:

ruby
# app/avo/scopes/admins.rb
class Avo::Scopes::Admins < Avo::Advanced::Scopes::BaseScope
class Avo::Scopes::Admins < Avo::Scopes::BaseScope

Search your app for any remaining references:

bash
rg 'Advanced::Scopes::BaseScope' app/

The bin/rails generate avo:scope generator already targets Avo::Scopes::BaseScope in Avo 4 — you only need to update existing scope files.

Nested association forms

If you use the nested option on has_many, has_one, or has_and_belongs_to_many fields, add avo-nested as shown in the Advanced features Gemfile diff above. See Nested in Forms on the association field pages.

Searchable association picker rewritten without Algolia

The searchable association picker was rewritten in Avo 4 using Hotwire (Stimulus + server-rendered HTML) instead of the Algolia autocomplete widget bundled in v3.

If you customized the v3 picker via CSS targeting Algolia's class names (.aa-Input, .aa-Panel, etc.), those selectors no longer match anything — the v4 picker uses Avo's own markup, and the Algolia stylesheet is no longer bundled.

Pagination

Replace size with slots

If you configured any resource pagination using the size option, update your pagination option from size to slots.

ruby
self.pagination = -> do
  {
    size: ... 
    slots: ... 
  }
end

Check the slots documentation for more details.

The Breadcrumbs API has been improved. This is mostly an internal change but you might have a few add_breadcrumb calls in your code. Sarch for those and update them to the new API using positional arguments.

add_breadcrumb now takes keyword arguments instead of positional arguments.

ruby
add_breadcrumb "Home", root_path
ruby
add_breadcrumb title: "Home", path: root_path

add_breadcrumb now takes icon and initials options for a more immersive experience.

ruby
add_breadcrumb title: "Home", icon: "heroicons/outline/home", initials: "AM"
add_breadcrumb title: "Home", icon: "tabler/outline/home", initials: "PB"

Renamed profile_photo to avatar

The profile_photo configuration has been renamed to avatar.

ruby
# Before
self.profile_photo = {
  source: :profile_photo # an Active Storage field or a path
}

# After
self.avatar = {
  source: :avatar # an Active Storage field or a path
}

The new avatar field will be used throughtout the app to display the record in a visual way.

Renamed cover_photo to cover

The cover_photo configuration has been renamed to cover.

ruby
# Before
self.cover_photo = {
  source: :cover_photo # an Active Storage field or a path
}

# After
self.cover = {
  source: :cover # an Active Storage field or a path
}

Grid Item Badge DSL tweaks

The grid item badge configuration has been updated from flat properties to a nested hash structure.

ruby
# Avo 3.15
self.grid_view = {
  card: -> do
    {
      title: record.title,
      badge_label: record.status,        
      badge_color: status_color,         
      badge_title: "Status: #{record.status}"
    }
  end
}

# Avo 4
self.grid_view = {
  card: -> do
    {
      title: record.title,
      badge: {                           
        label: record.status,            
        color: status_color,             
        style: "solid",                  
        title: "Status tooltip",         
        icon: "heroicons/outline/check"
      }                                   
    }
  end
}

Migration steps

  1. Replace flat badge properties with a badge hash:

    • badge_labelbadge: { label: ... }
    • badge_colorbadge: { color: ... }
    • badge_titlebadge: { title: ... }
  2. Add optional new properties if needed:

    • badge: { style: ... } - Controls badge appearance (subtle or solid)
    • badge: { icon: ... } - Adds an icon to the badge

For detailed information about available colors, styles, and icons, see the Badge field documentation.

See the Grid Item Badge documentation for more details on all available options.

Discreet information updates

We've made a few updates to the discreet information API to make it more versatile.

API tweaks

  1. Removed id_text and id_badge as they didn't really look good. Use id instead.
  2. The timestamps_badge was removed
  3. New :created_at and :updated_at types which show the timestamps as a key-value pair.
  4. label is now text
  5. Renamed url_target to target
  6. as can be icon, text, badge, key_value
  7. key_value has key and value options

Removed cluster (and its alias row) in favor of width

The cluster DSL method (and its alias row) has been removed. Previously you wrapped fields in a cluster do ... end block to place them side-by-side. Now every field has a width option and Avo lays them out together automatically — adjacent fields with a width below 100 will sit on the same row.

Replace cluster / row with width

ruby
# Avo 3
cluster do
  field :company, stacked: true do
    "TechCorp Inc."
  end
  field :department, stacked: true do
    "Research & Development"
  end
end

# Avo 4
field :company, width: 50 do
  "TechCorp Inc."
end
field :department, width: 50 do
  "Research & Development"
end

The same applies to row, which was just an alias for cluster:

ruby
# Avo 3
row do
  field :street_address, stacked: true
  field :city, stacked: true
end

# Avo 4
field :street_address, width: 50
field :city, width: 50

Supported width values

width is given as a percentage. The supported values are 25, 33, 50, 66, 75, and 100 (the default).

widthClassApprox. fraction
25w-1/4¼
33w-1/3
50w-1/2½
66w-2/3
75w-3/4¾
100w-fullfull row

Setting any width below 100 automatically marks the field as stacked: true, so you no longer need to repeat stacked: true next to a custom width.

If you used cluster divider: true to draw a divider between clustered fields, drop the option — the divider is no longer needed in the new layout.

Removed field wrapper options

The compact and short props have been removed from Avo::FieldWrapperComponent. Fields now adapt to their context automatically, so no replacement is needed. If you had custom components that passed compact: or short: into the wrapper, remove those arguments.

New use_stacked_fields configuration

A new global configuration toggles the stacked layout across every field in the app:

ruby
# config/initializers/avo.rb
Avo.configure do |config|
  config.use_stacked_fields = true # default: false
end

When set to true, fields render with the stacked layout by default without needing stacked: true on each one. You can still override per field.

Map view tweaks

The map view positioning option was reworked so it reflects the position of the map instead of the position of the table.

  • The configuration key moved from table.layout to map.position.
  • Allowed values stay the same (:top, :right, :bottom, :left) but the semantic flipped — you now describe where the map sits, not where the table sits.
ruby
self.map_view = {
  table: {
    visible: true,
    layout: :bottom
  },
  map: {                
    position: :top
  }                     
}

Translation between the old and new keys:

Avo 3 (table.layout)Avo 4 (map.position)
:bottom:top
:top:bottom
:left:right
:right:left

The default map style is now mapbox://styles/mapbox/light-v11 and the default height is 26rem when the map is positioned vertically. You can still override these via mapkick_options.

Added label_help option

This option allows you to add a help text to the label of a field on Show and Edit views.

Related resources:

Cards description option renamed to discreet_description

The old description option was renamed to discreet_description The current description option is used for the card description under the title.

Removed config.full_width_index_view configuration for config.container_width

The config.full_width_index_view configuration has been removed in favor of the config.container_width configuration.

ruby
# Before
config.full_width_index_view = true

# After
config.container_width = { index: :full }

More info on the Container width section.

Added sidebar_toggle_visible configuration option

More info on the Toggle the sidebar button visibility section.

Added self.description option to actions

More info on the Description option section.

Added self.icon option to resources

More info on the Icon option section.

Added loading: :manual for on-demand associations and tabs

Avo 4 adds a loading: option to has_many, has_one, and has_and_belongs_to_many association fields and to tabs. It introduces a third frame-loading mode — manual — alongside the existing eager and lazy behavior.

A manual frame renders a placeholder with a Load button and fetches nothing until the user clicks it. It's handy for expensive associations or tabs (heavy queries, external APIs) that a user often doesn't need on every visit.

ruby
# Associations
field :orders, as: :has_many, loading: :manual
field :invoice, as: :has_one, loading: :manual
field :tags, as: :has_and_belongs_to_many, loading: :manual

# Tabs — every manual tab gets its own Load button
tab title: "Orders", loading: :manual do
  field :orders, as: :has_many
end

The option is purely additive and opt-in — omitting loading: keeps the exact behavior you have today. It applies on the Show view only; Edit and New always render the full field/tab.

Blocks that feed the placeholder still run on page load

manual defers the framed content, not the placeholder. The placeholder (its title and description:) is rendered with the page, so any lambda/block that feeds it is evaluated on every page load — before the user clicks Load.

A description: lambda that touches the database is therefore not deferred:

ruby
field :posts, as: :has_many,
  loading: :manual,
  description: -> { "#{query.count} posts" } # `query.count` runs on page load

If you reached for loading: :manual to avoid a query until the frame is opened, keep the description: cheap (or drop it) — only the framed content is fetched on demand.

Remembering an opened frame with auto_load_for

A manual frame remembers that the user opened it and skips the Load button on subsequent visits within a time window. This defaults to 15 minutes — once the user clicks Load, returning to the page within the window auto-loads the frame with no button.

Pass auto_load_for to change the window, or 0/nil to opt out (placeholder returns on every visit):

ruby
field :orders, as: :has_many, loading: { mode: :manual, auto_load_for: 5.minutes }
field :orders, as: :has_many, loading: { mode: :manual, auto_load_for: 0 } # no memory

Avo stores a short-lived cookie (scoped per record + association/tab). While it's valid, returning to the page — a refresh, the back button, or following a link back — renders the frame as a normal auto-loading one, with no button. The window slides: each return resets the clock. When it lapses, the placeholder and Load button come back.

mode: also accepts :lazy to render a native lazy frame (loads when scrolled into view):

ruby
field :orders, as: :has_many, loading: { mode: :lazy }

Added config.associations for global association defaults

Avo 4 introduces a config.associations namespace that sets the defaults for all associations in one place:

ruby
# config/initializers/avo.rb
Avo.configure do |config|
  config.associations = {
    lookup_list_limit: 1000,
    frames: {
      loading: :lazy,           # default render mode for has_one/has_many/habtm frames
      auto_load_for: 15.minutes # default manual memory window
    }
  }
end

A per-field loading: always overrides frames.loading. See Customization → Associations.

config.associations_lookup_list_limit moved into the namespace

The lookup limit now lives at config.associations[:lookup_list_limit]. The old flat accessor still works as an alias, so existing initializers don't break:

ruby
config.associations_lookup_list_limit = 1000
config.associations = {lookup_list_limit: 1000} 

Association frames now default to lazy loading

frames.loading defaults to :lazy, so top-level association frames load when revealed rather than eagerly on page load (associations inside tabs were already lazy). To restore eager loading, set loading: :eager — either per field, or globally via config.associations = {frames: {loading: :eager}}.

Added view.single? method

The view.single? method has been added to the view object.

ruby
if view.single?
  # Code for the "show", "edit", and "new" views
end

More info on the View object section.

Dynamic Filters always_expanded default changed to true

In Avo 3 the dynamic filters bar was collapsed by default behind a toggle button. In Avo 4 the default for Avo::DynamicFilters.configuration.always_expanded was flipped from false to true, so the filters bar is now shown expanded out of the box and the toggle button is hidden.

If you want to restore the previous behavior (collapsed by default, with a toggle button), set the option to false explicitly in your config/initializers/avo.rb:

ruby
if defined?(Avo::DynamicFilters)
  Avo::DynamicFilters.configure do |config|
    config.always_expanded = false
  end
end

More info on the always_expanded option section.

explicit_authorization default changed to true

In Avo 3 the default for config.explicit_authorization was false, so an action whose policy class or method was missing fell back to being authorized. In Avo 4 the default was flipped to true, so any action without an explicit policy method is now denied by default.

This is a safer default, but it can hide records, fields, or actions that were previously visible if your policies are incomplete. Review your policies to make sure every action you expect to be available has a corresponding policy method defined.

If you want to restore the previous behavior, set the option back to false in your config/initializers/avo.rb:

ruby
Avo.configure do |config|
  config.explicit_authorization = false
end

More info on the explicit_authorization option section.