Build model-agnostic forms and sidebar-navigable page hierarchies in an Avo admin — screens that aren't CRUD on a record. A form declares fields with the full Avo field DSL and handles its own submission in plain controller code; pages organize forms into a main page + sub-pages with their own sidebar. Use when the user wants a settings or preferences screen with no backing model, an app configuration / feature-flags UI, a data-import or upload-and-process form, a custom admin workflow form, a form that updates several models or kicks off a background job on submit, a multi-section settings area with a sidebar, or a standalone form to drop into any view — whether they say it in Avo terms ("Avo::Forms::Core::Form", "def handle", "def navigation", "def content", "avo:form / avo:page generator", "avo-forms add-on") or as a plain product ask ("a settings page that isn't tied to a record", "a preferences screen", "an admin import form", "a page that saves config").
Build model-agnostic forms and sidebar-navigable page hierarchies in an Avo admin — screens that aren't CRUD on a record. A form declares fields with the full Avo field DSL and handles its own submission in plain controller code; pages organize forms into a main page + sub-pages with their own sidebar. Use when the user wants a settings or preferences screen with no backing model, an app configuration / feature-flags UI, a data-import or upload-and-process form, a custom admin workflow form, a form that updates several models or kicks off a background job on submit, a multi-section settings area with a sidebar, or a standalone form to drop into any view — whether they say it in Avo terms ("Avo::Forms::Core::Form", "def handle", "def navigation", "def content", "avo:form / avo:page generator", "avo-forms add-on") or as a plain product ask ("a settings page that isn't tied to a record", "a preferences screen", "an admin import form", "a page that saves config").
Build standalone forms and page hierarchies in an Avo admin — screens that aren't CRUD on a database record. A form is a class under app/avo/forms/ that declares its fields (with the full Avo field DSL) and handles its own submission. A page under app/avo/pages/ organizes forms into a sidebar-navigable interface: a main page holds the navigation, sub-pages hold the content.
Reach for this for application settings, user preferences, feature-flag toggles, data imports, or any custom admin workflow that ends in "do something" rather than "save this record."
Two things to fix before writing any code:
Form, page, or both? A form is one screen of fields + a submit handler. A page is the sidebar container that hosts one or more forms (and sub-pages). A single form linked from the menu needs no page at all — see Render a form standalone. A settings area with multiple sections needs a main page + sub-pages.
This is a paid, Beta add-on.avo-forms is a licensed add-on (not Community) and currently Beta — the handle controller contract is flagged experimental and may change. Requires Avo >= 4.0 with the add-on enabled on the license.
Docs — fetch on demand with WebFetch; prefer the raw .md (clean, no HTML). Read the page before implementing anything non-trivial.
The engine hooks Avo's avo_boot and registers itself via Avo.plugin_manager (it also contributes the page / all_pages / form / all_forms menu DSL). Then scaffold with the generators:
Related skills: the field types/options inside fields are avo-fields (and panels/cards layout); adding pages/forms to the main menu is avo-navigation-search; rendering a form's .component inside custom markup is avo-custom-ui.
Workflow
Decide the shape — one form, or a page hosting several. Keep the hierarchy shallow (a main page + one level of sub-pages covers almost everything).
Generate the form(s) with bin/rails generate avo:form <name>; fill in fields and handle.
Generate the page with bin/rails generate avo:page <name> if you need a container; declare navigation (main page) and/or content (sub-page).
Wire children — reference sub-pages/forms from the main page's navigation; list forms in each sub-page's content.
Add it to the menu — pages don't appear until you add them (menu section).
Verify — boot the app, open the page, submit the form, confirm the flash and side effects.
Forms
A form inherits from Avo::Forms::Core::Form. It needs only two methods: fields (what to show) and handle (what to do on submit). title, description, and routing all have sensible defaults.
# app/avo/forms/app_settings.rbclassAvo::Forms::AppSettings < Avo::Forms::Core::Formself.title = "Application Settings"self.description = "Manage your application configuration"deffields
field :app_name, as::text
field :maintenance_mode, as::booleanenddefhandle
flash[:notice] = "Settings updated successfully"
default_response
endend
Build fields
Declare fields inside fields with the same field DSL as resources and actions — every Avo field type and option works (required:, default:, help_text:, placeholder:, width:, record:, …). See avo-fields for the full catalog.
deffields
field :email, as::text, required:true
field :notifications, as::boolean, default:true
field :theme, as::select, options: { light:"Light", dark:"Dark" }
end
Group with cards and panels. Wrap related fields in card or panel; both take title: and description::
deffields
card title:"Personal Information"do
field :first_name, as::text
field :last_name, as::textend
panel title:"Feature Flags", description:"Toggle features"do
field :enable_registrations, as::boolean, default:true
field :max_upload_size, as::number, default:10, help_text:"In MB"endend
Lay fields side by side with width:. with_options applies one option to a whole group:
deffields
card do
with_options width:50do
field :first_name, as::text
field :last_name, as::textendendend
Prefill from a record by binding a field (or a with_options group) to it via record:. A form has no backing record of its own, so this is how you show current values:
deffields
with_options record:Avo::Current.user do
field :first_name, as::text
field :last_name, as::text
field :email, as::textendend
Handle submission
handle runs in the controller's context — the submitted data is in params, and every controller helper (current_user, flash, cookies, redirect_to, params.permit, path helpers) is available directly. It's plain Rails: update several models, branch on input, enqueue a job. Finish by calling default_response for the standard redirect-back Turbo Stream response (or issue your own redirect_to).
defhandleImportJob.perform_later(params[:file]) if params[:import_data].present?
Post.create(title: params[:title], body: params[:body])
default_response
end
Flash messages — set flash[:notice] / :error / :success / :warning before returning. A String is a simple message; a Hash controls the timeout (a millisecond number, or :forever to keep it until dismissed):
The generator gives each form its own file — the default, and the right choice for anything reused across pages or rendered as a component. For a form that belongs to exactly one page, you can nest it inline in the page class, but a nested Avo::Pages::Settings::Integrations::ApiConfiguration doesn't read as a form. Give it its own file the moment you reuse it.
Render a form standalone
A form can be dropped into any view as a component — no page required:
To surface a single form in the menu without a page, use the form menu DSL (see menu section).
Pages
A page inherits from Avo::Forms::Core::Page. Hierarchy is derived from namespace depth:
Main page — one level under Avo::Pages (e.g. Avo::Pages::Settings). A container that gets a sidebar menu entry and defines navigation.
Sub-page — nested deeper (e.g. Avo::Pages::Settings::General). Holds the actual forms via content, reached through the parent's navigation.
When a user opens a main page, Avo redirects to its default: sub-page if one is marked; otherwise it renders the main page's own content. Sub-pages appear in a sidebar for switching.
Navigation (main page). Register children inside navigation with page and form. Mix all three styles freely:
# app/avo/pages/settings.rbclassAvo::Pages::Settings < Avo::Forms::Core::Pageself.title = "Settings"self.description = "Manage your application settings"defnavigation# Reference an existing sub-page class; mark one as the landing page:
page Avo::Pages::Settings::General, default:true
page Avo::Pages::Settings::Security# A single form, no dedicated sub-page file — becomes a sidebar entry:
form Avo::Forms::UserProfiles# A virtual (inline) page, declared with its content:
page "Integrations",
description:"Connect third-party services",
content: -> do
form Avo::Forms::Settings::Slack
form Avo::Forms::Settings::Emailendendend
Content (sub-page). List the forms with form; they render in declaration order, each with its own header. Drop a form's header for a given placement with show_header: false:
# app/avo/pages/settings/general.rbclassAvo::Pages::Settings::General < Avo::Forms::Core::Pageself.title = "General Settings"defcontent
form Avo::Forms::AppSettings
form Avo::Forms::CompanyInfo, show_header:falseendend
Menu label. A page's menu label defaults to its title. Set self.navigation_label for a shorter/different label in the menu than the on-page title.
Add to the menu
Pages don't appear in Avo's navigation until you add them, in config.main_menu. Reference a page by class string (a String avoids autoloading it at parse time), or pull them all in with all_pages. form / all_forms link forms directly:
# config/initializers/avo.rbAvo.configure do |config|
config.main_menu = -> {
section "Configuration", icon:"tabler/outline/settings"do
page "Avo::Pages::Settings"# one page# all_pages # or every main page at once# form Avo::Forms::AppSettings # a form as a direct menu linkend
}
end
Adding menu icons/structure is avo-navigation-search territory.
Gotchas
Beta add-on, paid. Not Community — needs the forms add-on enabled on an Avo >= 4.0 license. It self-registers, so there is no initializer to change; if it's not loading, check the Gemfile source and the license, not config.
handle is EXPERIMENTAL. It runs in raw controller context by design (full Rails toolbox), but the contract may change in a future release. Don't build brittle assumptions on internals beyond the documented helpers (params, current_user, flash, cookies, redirect_to, default_response).
navigation and content are parsed ONCE at boot. They're evaluated a single time during application boot, not per request. Keep them static — no conditionals, no dynamic/user-dependent logic, no per-request branching. Dynamic behavior belongs in handle or in the field default:/visibility options.
Always validate and permit params in handle before acting — it's an open controller action. Use params.permit(...); never mass-assign raw params.
Pages are invisible until added to the menu. Generating a page doesn't surface it — add it via page/all_pages in config.main_menu.
A main page redirects to its default: sub-page. If a main page is mostly a container, mark one child default: true so it lands somewhere useful; without a default it renders the main page's own (often empty) content.
id must be unique across all forms, and across all pages. Routes resolve by id at request time (no per-class routes). id defaults to the class path (Avo::Pages::Settings::General → settings/general); override with self.id. A collision means one screen shadows another.
Give a reused form its own file. Inline (nested-in-page) forms are fine for a single, page-specific form, but a deeply nested form class doesn't read as a form and can't be cleanly reused — extract it to app/avo/forms/ the moment a second page wants it.
form present only implicitly. Forms have no backing record and behave like a "new" view — this is why default: values fill in and why prefilling needs record:.
Report
When done, tell the user:
What you built — each form (with its fields and what handle does: which models it writes, jobs it enqueues, params it permits) and each page (main vs. sub, its navigation/content), with absolute file paths.
How it's reached — the sub-page hierarchy, any default: sub-page, and the exact config.main_menu entry you added (or that the user still needs to add).
Any standalone .component render sites.
Follow-ups they own: enabling the add-on on the license, permitting/validating params in handle, backing models responding to the attributes, and that navigation/content must stay static (boot-time) — plus the Beta caveat on handle.