Rustango docs
← Guides

HTML views — server-rendered pages

An HTML view turns a model into server-rendered web pages — a list page, a detail page, and create/edit/delete forms — from one declaration. It's the sibling of ViewSets: where a ViewSet emits JSON for API clients, an HTML view emits a rendered page for a browser. Both are built from the same #[derive(Model)], and you can serve a model both ways at once.

These are Rustango's equivalent of Django's generic class-based views (ListView, DetailView, CreateView, UpdateView, DeleteView) or Laravel's resource controllers returning Blade views. They render through Tera templates.

HTML views in Rustango: one model feeds ListView, DetailView and CreateView/UpdateView/DeleteView, each rendering a Tera template into a server-rendered page

New to a term here? If model, template, router or server-rendered are unfamiliar, the glossary explains each in plain language.

Source: rustango::template_views (ListView, DetailView, CreateView, UpdateView, DeleteView, TemplateView, RedirectView) — behind the template_views feature (on by default).

Runnable version: the API-vs-HTML example below is pinned by the framework test html_and_api_contrast_sqlite_live.rs (cargo test -p rustango --features sqlite --test html_and_api_contrast_sqlite_live). The individual views are covered by template_view.rs and template_views_context_object_name_sqlite_live.rs.

Table of contents


API views vs HTML views — which do you want?

This is the first decision. Both turn a model into endpoints; they differ in what comes out and who's calling.

API viewViewSetHTML view — this guide
Modulerustango::viewsetrustango::template_views
Sends backJSON dataa server-rendered HTML page
Built forSPAs, mobile apps, other servicesbrowsers, server-rendered sites, admin-style CRUD
A "create"POST JSON → 201 + the new objectPOST a form → 303 redirect to a success page
On bad input400 + a field-keyed JSON error mapre-render the form with the errors shown
Reads a list asa paginated JSON envelopea <table>/loop in your template
Usually authed bytokens / JWT / API keyssession cookies
Django analogueDRF ModelViewSetgeneric class-based views

You don't have to choose globally — pick per resource, and you can mount both on the same model (see below). Rules of thumb:

  • Building a JSON backend for a frontend framework or mobile app → ViewSet.
  • Building a server-rendered site (the server returns HTML pages) → HTML views.
  • Need both (a public API and internal CRUD pages) → mount both.

Looking for the JSON side? It has its own deep-dive: ViewSets — CRUD REST APIs.


The five model views

Each view is for_model(SCHEMA) plus a .router(prefix, tera, pool). Mounting them at the same prefix (say /posts) gives the classic CRUD URL set:

ViewRendersRoutes mountedDefault template
ListViewa paginated listGET <prefix><table>_list.html
DetailViewone rowGET <prefix>/{pk}<table>_detail.html
CreateViewa new-record formGET/POST <prefix>/new<table>_form.html
UpdateViewa prefilled edit formGET/POST <prefix>/{pk}/edit<table>_form.html
DeleteViewa confirm pageGET/POST <prefix>/{pk}/delete<table>_confirm_delete.html

<table> is the model's table name, so a Post (table posts) looks for posts_list.html, posts_detail.html, and so on. Override any of them with .template("my_name.html").


ListView

A paginated list page. You provide a template that loops over object_list; the view handles paging, ordering, filtering and search from query params.

use rustango::template_views::ListView;
use std::sync::Arc;
use tera::Tera;

let app = ListView::for_model(Post::SCHEMA)
    .page_size(20)                       // rows per page (?page=N to navigate)
    .order_by("published_at", true)      // default sort, true = DESC
    .filter_fields(&["status", "author_id"])  // ?status=published
    .search_fields(&["title", "body"])        // ?search=rust
    .router("/posts", Arc::new(tera), pool);

A matching posts_list.html — note object_list and the pagination variables the view stamps for you:

<h1>Posts ({{ total }})</h1>
{% for post in object_list %}
  <article>
    <h2><a href="/posts/{{ post.id }}">{{ post.title }}</a></h2>
    <p>{{ post.body }}</p>
  </article>
{% endfor %}

{% if has_prev %}<a href="?page={{ page - 1 }}">← prev</a>{% endif %}
page {{ page }} / {{ total_pages }}
{% if has_next %}<a href="?page={{ page + 1 }}">next →</a>{% endif %}

?page=, ?status=, ?search= and ?ordering= work the same as on a ViewSet list — the difference is purely that the result is a rendered page rather than a JSON envelope. Use .context_object_name("posts") if you'd rather loop over posts than object_list in the template.


DetailView

One row, looked up from the URL. By default it matches the primary key (/posts/42); point it at another column with .lookup_field("slug") for pretty URLs (/posts/my-first-post). A missing row is a 404.

use rustango::template_views::DetailView;

let app = DetailView::for_model(Post::SCHEMA)
    .lookup_field("slug")          // GET /posts/{slug} instead of /posts/{id}
    .router("/posts", Arc::new(tera), pool);

The template gets the row as object:

<h1>{{ object.title }}</h1>
<p>{{ object.body }}</p>
<small>by author #{{ object.author_id }}</small>

CreateView, UpdateView, DeleteView

The write side. Each handles a GET (render a form / confirm page) and a POST (do the work, then redirect). The redirect-after-POST is the standard Post/Redirect/Get pattern — it stops a browser refresh from re-submitting.

CreateViewGET /posts/new renders an empty form; POST /posts/new inserts the row and 303s to success_url:

use rustango::template_views::CreateView;

let app = CreateView::for_model(Post::SCHEMA)
    .success_url("/posts")         // where to send the browser after a save
    .router("/posts", Arc::new(tera), pool);

The form template (posts_form.html) is shared with UpdateView. is_update tells the two apart, and errors carries any validation messages back:

<form method="post">
  <input name="title" value="{{ object.title | default(value='') }}">
  <textarea name="body">{{ object.body | default(value='') }}</textarea>
  {% for field, msgs in errors %}
    <p class="error">{{ field }}: {{ msgs | join(sep=', ') }}</p>
  {% endfor %}
  <button>{% if is_update %}Save{% else %}Create{% endif %}</button>
</form>

Validation. Schema rules (type, max_length, NOT NULL…) are enforced automatically. Add your own with a closure validator — on Err, the form re-renders with the messages and a 422 status instead of saving:

use rustango::forms::FormErrors;

CreateView::for_model(Post::SCHEMA)
    .validator(|data| {
        let mut errs = FormErrors::default();
        if data.get("title").map_or(true, |t| t.len() < 5) {
            errs.add("title", "must be at least 5 characters");
        }
        if errs.is_empty() { Ok(()) } else { Err(errs) }
    })
    .success_url("/posts")
    .router("/posts", Arc::new(tera), pool);

You can also reuse a #[derive(Form)] struct's validators with .form::<F>() (validation-only for now — see the API docs).

UpdateViewGET /posts/{pk}/edit renders the same form prefilled from the row (object is populated, is_update is true); POST updates and 303s.

use rustango::template_views::UpdateView;

UpdateView::for_model(Post::SCHEMA)
    .success_url("/posts")
    .router("/posts", Arc::new(tera), pool);

DeleteViewGET /posts/{pk}/delete renders a confirmation page (posts_confirm_delete.html, with object); POST deletes and 303s.

use rustango::template_views::DeleteView;

DeleteView::for_model(Post::SCHEMA)
    .success_url("/posts")
    .router("/posts", Arc::new(tera), pool);

Mount all five at the same prefix and you have full HTML CRUD:

let app = axum::Router::new()
    .merge(ListView::for_model(Post::SCHEMA).router("/posts", tera.clone(), pool.clone()))
    .merge(DetailView::for_model(Post::SCHEMA).router("/posts", tera.clone(), pool.clone()))
    .merge(CreateView::for_model(Post::SCHEMA).success_url("/posts").router("/posts", tera.clone(), pool.clone()))
    .merge(UpdateView::for_model(Post::SCHEMA).success_url("/posts").router("/posts", tera.clone(), pool.clone()))
    .merge(DeleteView::for_model(Post::SCHEMA).success_url("/posts").router("/posts", tera, pool));

The Tera context

Every view stamps a consistent context so templates port cleanly between them:

ViewVariables available in the template
ListViewobject_list (the page's rows), page, page_size, total, total_pages, has_next, has_prev
DetailViewobject (the row)
CreateView / UpdateViewobject (empty on create, prefilled on update), is_update (bool), errors, values
DeleteViewobject (the row to confirm)

Rows are exposed as plain maps keyed by column name ({{ post.title }}), with SQL NULL rendered as null. Use .context_object_name("posts" / "post") to add a friendlier alias alongside object_list / object.


TemplateView and RedirectView

Two model-free helpers for the pages every site has:

TemplateView — render a static template with a fixed context (an "about" page, a landing page). No model, no database:

use rustango::template_views::TemplateView;

let app = TemplateView::new("about.html")
    .context_value("title", "About us")
    .router("/about", Arc::new(tera));

RedirectView — a permanent or temporary redirect at a URL (for moved pages):

use rustango::template_views::RedirectView;

let app = RedirectView::to("/posts").router("/old-posts");

Single-tenant vs multi-tenant

Every model view ships two router constructors — same builder, pick the one that matches how your app manages database connections:

  • .router(prefix, tera, pool) — single-tenant; captures one pool at mount time. This is what the examples above use.
  • .tenant_router(prefix, tera) — multi-tenant; resolves a per-request connection from the Tenant extractor. Available with the template_views + tenancy features. Templates port between the two unchanged.

This mirrors the ViewSet split (router / router_pool vs tenant_router).


Serving one model both ways

You're not limited to one front door. Mount a JSON API and HTML pages over the same model and pool — a public API for clients, server-rendered pages for people:

use rustango::viewset::ViewSet;
use rustango::template_views::{ListView, DetailView};

let app = axum::Router::new()
    // JSON for API clients:
    .merge(ViewSet::for_model(Post::SCHEMA).router_pool("/api/posts", pool.clone()))
    // HTML pages for browsers:
    .merge(ListView::for_model(Post::SCHEMA).router("/posts", tera.clone(), pool.clone()))
    .merge(DetailView::for_model(Post::SCHEMA).router("/posts", tera, pool));

Now GET /api/posts returns the paginated JSON envelope and GET /posts returns a rendered HTML list — same rows, same pool, two shapes. This exact setup is what the backing test asserts.


See also