Blog Example Walkthrough
The blog example is the canonical axum-admin demo. It registers three related entities — Categories, Posts, and Tags — and shows foreign keys, many-to-many relationships, enum fields, search, and filters all working together.
Source: examples/blog/
What it builds
| Entity | Table | Notable fields |
|---|---|---|
| Category | categories | id, name |
| Post | posts | id, title, body, status (enum), category_id (FK) |
| Tag | tags | id, name |
| Post–Tag join | post_tags | post_id, tag_id |
The admin UI exposes:
- Full CRUD for all three entities
- Post list with search across
titleandbody - Post list with filters on
statusandcategory_id - A foreign-key picker for category on the post form
- A many-to-many tag selector on the post form
- Auto-detected enum select for
status(Draft / Published) - A default admin user (
admin/admin) created on first boot
Database setup
The example ships a docker-compose.yml that starts a Postgres 16 container:
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: blog
POSTGRES_PASSWORD: blog
POSTGRES_DB: blog
ports:
- "5432:5432"
Start the database:
cd examples/blog
docker compose up -d db
The app defaults to postgres://blog:blog@localhost:5432/blog. Override with DATABASE_URL if needed.
Running the example
cd examples/blog
cargo run
Migrations run automatically on startup. Open http://localhost:3000/admin and log in with admin / admin.
To run the full stack (database + app) in containers:
docker compose up
Walkthrough: main.rs
Entity models
All three entities are defined inline in main.rs as Sea-ORM entities. This keeps the example self-contained — in a real project these would live in their own modules or crates.
mod post {
#[derive(Clone, Debug, PartialEq, EnumIter, DeriveActiveEnum)]
#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")]
pub enum Status {
#[sea_orm(string_value = "draft")]
Draft,
#[sea_orm(string_value = "published")]
Published,
}
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "posts")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub title: String,
pub body: String,
pub status: Status,
pub category_id: Option<i32>,
}
// ...
}
Key points:
StatusderivesDeriveActiveEnum— axum-admin detects this automatically and renders a<select>with "Draft" and "Published" options. No extra configuration needed.category_idisOption<i32>— nullable, meaning a post does not have to belong to a category.
Startup sequence
#[tokio::main]
async fn main() {
let db = connect_db().await;
migration::Migrator::up(&db, None).await.expect("...");
let router = admin::build(db).await;
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, router).await.unwrap();
}
- Connect — reads
DATABASE_URLfrom the environment, falls back to the Docker default. - Migrate — runs all pending Sea-ORM migrations before accepting traffic. This is safe to run on every boot.
- Build — delegates to
admin::build(db), which returns an AxumRouter. - Serve — hands the router to Axum's standard server loop.
Walkthrough: admin.rs
Auth setup
pub async fn build(db: DatabaseConnection) -> Router {
let auth = SeaOrmAdminAuth::new(db.clone())
.await
.expect("failed to initialize auth");
auth.ensure_user("admin", "admin")
.await
.expect("failed to ensure admin user");
SeaOrmAdminAuth creates the admin_users and admin_roles tables (via its own migrations) and wires up session-based login. ensure_user is idempotent — it creates the user on first run, does nothing on subsequent boots.
AdminApp configuration
AdminApp::new()
.title("Blog Admin")
.icon("fa-solid fa-newspaper")
.prefix("/admin")
.template_dir(concat!(env!("CARGO_MANIFEST_DIR"), "/templates"))
.seaorm_auth(auth)
.title()— sets the name shown in the nav header..icon()— Font Awesome class for the logo icon..prefix("/admin")— all admin routes are mounted under this path..template_dir()— path to local Tera templates that override defaults. Theconcat!(env!(...))macro resolves the absolute path at compile time, which is required when the binary is run from a different working directory..seaorm_auth(auth)— attaches the authentication layer.
Entity group
.register(
EntityGroupAdmin::new("Blog")
.register(categories_admin)
.register(posts_admin)
.register(tags_admin)
)
EntityGroupAdmin is a named section in the sidebar. All three entities appear under a "Blog" heading. You can have multiple groups for larger applications.
Categories entity
EntityAdmin::from_entity::<category::Entity>("categories")
.label("Categories")
.icon("fa-solid fa-folder")
.list_display(vec!["id".to_string(), "name".to_string()])
.search_fields(vec!["name".to_string()])
.adapter(Box::new(SeaOrmAdapter::<category::Entity>::new(db.clone())))
from_entity— infers all fields from the Sea-ORM entity. Without explicit.field()calls, the admin generates default inputs for each column.list_display— controls which columns appear in the list view and their order.search_fields— enables the search box and specifies which columns to query with aLIKEfilter.adapter— the Sea-ORM adapter handles all database reads and writes for this entity.
Tags entity
Tags is identical to categories in structure — a simple two-column entity with search on name. It follows the same minimal pattern.
Posts entity — the interesting one
EntityAdmin::from_entity::<post::Entity>("posts")
.label("Posts")
.icon("fa-solid fa-file-lines")
.field(
Field::text("title")
.required()
.min_length(3)
.max_length(255),
)
.field(Field::textarea("body").max_length(10000))
.field(Field::foreign_key(
"category_id",
"Category",
Box::new(SeaOrmAdapter::<category::Entity>::new(db.clone())),
"id",
"name",
))
.field(
Field::many_to_many(
"tags",
Box::new(SeaOrmManyToManyAdapter::new(
db.clone(),
"post_tags", // join table
"post_id", // FK to this entity
"tag_id", // FK to related entity
"tags", // related table
"id", // related PK
"name", // related display column
)),
)
.label("Tags"),
)
.search_fields(vec!["title".to_string(), "body".to_string()])
.filter_fields(vec!["status".to_string(), "category_id".to_string()])
.adapter(Box::new(SeaOrmAdapter::<post::Entity>::new(db.clone())))
Field overrides — explicit .field() calls replace the auto-generated defaults for those columns:
Field::text("title")— single-line text input with server-side validation..required()makes the field mandatory..min_length(3).max_length(255)adds length constraints enforced on save.Field::textarea("body")— multi-line textarea..max_length(10000)prevents oversized payloads.
Foreign key field — Field::foreign_key renders a dropdown populated by querying the categories adapter. The four string arguments are: column name on posts, display label, adapter, PK column name, display column name. When editing a post, the dropdown shows category names but stores category IDs.
Many-to-many field — Field::many_to_many with SeaOrmManyToManyAdapter handles the post_tags join table transparently. On save, axum-admin diffs the selected tag IDs against the current join table rows and issues the appropriate inserts and deletes. The adapter arguments map directly to the join table schema.
Search and filters — search_fields queries both title and body columns. filter_fields adds sidebar filter widgets for status (rendered as an enum select because the column maps to Status) and category_id (rendered as a foreign-key dropdown).
Adapting this to your own project
- Define your Sea-ORM entities (or use existing ones from your domain crate).
- Call
AdminApp::new()and configure title, prefix, and auth. - For each entity, create an
EntityAdmin::from_entityand attach aSeaOrmAdapter. - Add explicit
Fielddefinitions only where you need validation, custom widgets, or relationship pickers — everything else is auto-detected. - Group related entities with
EntityGroupAdmin. - Call
.into_router().awaitand merge the resultingRouterinto your Axum application.