Getting Started with JSON-UI
Build server-rendered pages with Ferro's component system. No frontend toolchain required.
Prerequisites
- An existing Ferro application
- No additional dependencies -- JSON-UI is built into the framework
Your First View
Create a handler that returns a JSON-UI page. The view is a tree of components rendered to HTML with Tailwind classes.
1. Build the view
#![allow(unused)] fn main() { use ferro::{handler, JsonUi, JsonUiView, ComponentNode, Component, CardProps, TextProps, TextElement, Response}; #[handler] pub async fn dashboard() -> Response { let view = JsonUiView::new() .title("Dashboard") .layout("app") .component(ComponentNode { key: "welcome".to_string(), component: Component::Card(CardProps { title: "Welcome".to_string(), description: Some("Your application dashboard".to_string()), children: vec![ ComponentNode { key: "intro".to_string(), component: Component::Text(TextProps { content: "This page is rendered entirely from Rust.".to_string(), element: TextElement::P, }), action: None, visibility: None, }, ], footer: vec![], }), action: None, visibility: None, }); JsonUi::render(&view, &serde_json::json!({})) } }
2. Register the route
#![allow(unused)] fn main() { use ferro::get; get!("/dashboard", controllers::dashboard::dashboard); }
That's it. Visit /dashboard to see a styled card with your content, wrapped in the app layout with sidebar and navigation.
Adding a Form
Forms use the Form component with Input fields. The form's action references a named route that the framework resolves to a URL at render time.
Create form
#![allow(unused)] fn main() { use ferro::{handler, JsonUi, JsonUiView, ComponentNode, Component, FormProps, InputProps, InputType, SelectProps, SelectOption, Action, Response}; #[handler] pub async fn create() -> Response { let view = JsonUiView::new() .title("Create User") .layout("app") .component(ComponentNode { key: "form".to_string(), component: Component::Form(FormProps { action: Action::new("users.store"), fields: vec![ ComponentNode { key: "name".to_string(), component: Component::Input(InputProps { field: "name".to_string(), label: "Name".to_string(), input_type: InputType::Text, placeholder: Some("Enter full name".to_string()), required: Some(true), disabled: None, error: None, description: None, default_value: None, data_path: None, }), action: None, visibility: None, }, ComponentNode { key: "email".to_string(), component: Component::Input(InputProps { field: "email".to_string(), label: "Email".to_string(), input_type: InputType::Email, placeholder: Some("user@example.com".to_string()), required: Some(true), disabled: None, error: None, description: None, default_value: None, data_path: None, }), action: None, visibility: None, }, ComponentNode { key: "role".to_string(), component: Component::Select(SelectProps { field: "role".to_string(), label: "Role".to_string(), options: vec![ SelectOption { value: "user".to_string(), label: "User".to_string() }, SelectOption { value: "admin".to_string(), label: "Admin".to_string() }, ], placeholder: Some("Select a role".to_string()), required: Some(true), disabled: None, error: None, description: None, default_value: None, data_path: None, }), action: None, visibility: None, }, ], method: None, }), action: None, visibility: None, }); JsonUi::render(&view, &serde_json::json!({})) } }
Pre-fill an edit form
For edit forms, pass the existing record as data and use data_path on each field to bind values:
#![allow(unused)] fn main() { #[handler] pub async fn edit(user: User) -> Response { let view = JsonUiView::new() .title("Edit User") .layout("app") .component(ComponentNode { key: "form".to_string(), component: Component::Form(FormProps { action: Action::new("users.update"), fields: vec![ ComponentNode { key: "name".to_string(), component: Component::Input(InputProps { field: "name".to_string(), label: "Name".to_string(), input_type: InputType::Text, placeholder: None, required: Some(true), disabled: None, error: None, description: None, default_value: None, data_path: Some("/data/user/name".to_string()), }), action: None, visibility: None, }, ComponentNode { key: "email".to_string(), component: Component::Input(InputProps { field: "email".to_string(), label: "Email".to_string(), input_type: InputType::Email, placeholder: None, required: Some(true), disabled: None, error: None, description: None, default_value: None, data_path: Some("/data/user/email".to_string()), }), action: None, visibility: None, }, ], method: None, }), action: None, visibility: None, }); let data = serde_json::json!({ "user": { "name": user.name, "email": user.email, } }); JsonUi::render(&view, &data) } }
The data_path value "/data/user/name" tells the renderer to look up data.user.name and pre-fill the input.
Validation errors
When validation fails, use JsonUi::render_validation_error() to automatically populate error messages on the corresponding form fields:
#![allow(unused)] fn main() { use ferro::validation::{Validator, rules}; #[handler] pub async fn store(req: Request) -> Response { let form: serde_json::Value = req.json().await?; let result = Validator::new(&form) .rules("name", rules![required(), string()]) .rules("email", rules![required(), email()]) .validate(); if let Err(errors) = result { let view = create_form_view(); // reuse the view from create() return JsonUi::render_validation_error(&view, &serde_json::json!({}), &errors); } // Create user and redirect... Ok(HttpResponse::redirect("/users")) } }
The framework matches error field names ("name", "email") to input field values and sets the error prop on each matching component.
Adding a Table
Tables bind to a data path and render rows automatically from the handler data.
#![allow(unused)] fn main() { use ferro::{handler, JsonUi, JsonUiView, ComponentNode, Component, TableProps, Column, ColumnFormat, Action, PaginationProps, Response}; #[handler] pub async fn index() -> Response { let view = JsonUiView::new() .title("Users") .layout("app") .component(ComponentNode { key: "users-table".to_string(), component: Component::Table(TableProps { columns: vec![ Column { key: "name".to_string(), label: "Name".to_string(), format: None }, Column { key: "email".to_string(), label: "Email".to_string(), format: None }, Column { key: "created_at".to_string(), label: "Created".to_string(), format: Some(ColumnFormat::Date), }, ], data_path: "/data/users".to_string(), row_actions: Some(vec![ Action::get("users.edit"), Action::delete("users.destroy") .confirm_danger("Delete this user?"), ]), empty_message: Some("No users found".to_string()), sortable: None, sort_column: None, sort_direction: None, }), action: None, visibility: None, }) .component(ComponentNode { key: "pagination".to_string(), component: Component::Pagination(PaginationProps { current_page: 1, per_page: 25, total: 100, base_url: Some("/users".to_string()), }), action: None, visibility: None, }); let data = serde_json::json!({ "users": [ {"name": "Alice", "email": "alice@example.com", "created_at": "2026-01-15"}, {"name": "Bob", "email": "bob@example.com", "created_at": "2026-01-20"}, ] }); JsonUi::render(&view, &data) } }
Key points:
data_pathtells the table where to find its row data in the handler responserow_actionsadds action buttons to each row (Edit link, Delete with confirmation)ColumnFormat::Dateformats thecreated_atcolumn as a datePaginationrenders page navigation below the table
Using the CLI
Generate view files with the CLI:
ferro make:json-view UserIndex
With an Anthropic API key configured, the command reads your models and routes to generate a complete view file with appropriate components. Without an API key, it produces a static template as a starting point.
Next Steps
- Components -- Reference for all 20 built-in component types
- Actions -- Navigation, form submission, confirmations, and outcomes
- Data Binding & Visibility -- Data paths and conditional rendering
- Layouts -- Page structure, built-in layouts, and custom layouts