Umbral
Back to the blog
Tutorial Jul 9, 2026 7 min read

Forms, admin, and REST without the boilerplate

One model declaration drives the form, the admin CRUD, and the REST resource. Here's how the same struct powers server-rendered forms with validation, an auto admin, and a JSON API — each an opt-in plugin.

The payoff of declaring your data once is that several systems can read that declaration. In Umbral, the same model struct can drive a validated form, an admin CRUD screen, and a REST resource — each one an opt-in plugin.

One struct, three surfaces

#[derive(Debug, Clone, Default, sqlx::FromRow, Serialize, Deserialize, Model, umbral::forms::Form)]
#[umbral(plugin = "directory", display = "Plugins", icon = "package")]
pub struct Plugin {
    pub id: i64,

    #[umbral(unique, max_length = 120)]
    #[form(required, length(min = 2, max = 120))]
    pub name: String,

    #[form(required, length(min = 10, max = 400))]
    pub short_description: String,

    // server-managed: skipped by the form, stamped by the admin
    #[umbral(noform, choices, default = "pending")]
    pub moderation: PluginModeration,
}

Forms

The Form derive turns the #[form(...)] attributes into server-side validation with friendly errors. Fields marked #[umbral(noform)] never appear on the public form — a visitor can't set their own moderation status.

let plugin = Plugin::validate(&submitted_data).await?;

Admin

Register the model and the admin gives you a CRUD UI — list, filters, create, edit — with the #[umbral(noform)] fields editable by staff. The dashboard can add widgets that read straight from the model.

REST

Expose the same model as a JSON resource, hiding sensitive columns, in one line:

RestPlugin::default()
    .resource(ResourceConfig::for_::<AuthUser>().hide(["password_hash"]))

Pair it with the OpenAPI plugin and you get a schema and a request playground for free.

The ORM is the single interface

Every row-level read or write goes through the ORM — never hand-rolled sqlx::query("...") in plugin code. The ORM knows the backend and emits the right SQL, so one path works on Postgres and SQLite alike:

let pending = Plugin::objects()
    .filter(plugin::MODERATION.eq("pending"))
    .order_by(plugin::CREATED_AT.desc())
    .fetch()
    .await?;

Declare once, and the form, the admin, the API, and your queries all read the same truth. That's the boilerplate you didn't write.