Skip to content

A back office without a JavaScript framework

· 4 min read · Renoxium

Back offices are where single-page apps usually take over. A list of records needs filters on every column, search, sorting, paging, grouping, actions on one row or a selection, exports, and a layout that still works on a phone. The usual answer is a JavaScript data grid fed by a JSON API, which means two apps to build and keep in sync.

A back office built on Renox has none of that. Its grids and charts are rendered on the server, and htmx swaps in the parts that change. This post shows how one is built, with code from a client portal we built on Renox.

A grid is a value

A grid is described in Rust: an id, its columns, and options. Here is the portal’s activity log (one badge left out):

use renox::grid::{Column, Grid, GridRequest};

pub fn grid() -> Grid {
    Grid::new("activity")
        .title("Activity")
        .per_page(25)
        .column(Column::datetime("created_at", "When").mobile())
        .column(
            Column::related("user", "Who", "users", "user_id", "name")
                .mobile()
                .searchable(),
        )
        .column(
            Column::text("action", "What")
                .mobile()
                .searchable()
                .badges(&[
                    ("auth.login", "success"),
                    ("auth.login_failed", "danger"),
                    ("user.roles_changed", "warning"),
                ]),
        )
        .column(Column::text("data", "Details").limit(60).tooltip("data"))
        .column(Column::text("ip", "IP").hidden())
        .sort_by("-created_at")
        .exports()
}

Each kind of column comes with a filter in its heading that fits the data: text gets contains or starts with, numbers get a range, dates get a calendar, choices get checkboxes. .mobile() picks the columns shown on a phone. .hidden() columns can be turned on from the column menu, which also moves, freezes and resizes columns and saves the choices per user.

The handler is a few lines

async fn index(request: GridRequest) -> Result<Response> {
    if let Some(file) = grid().export(Entry::query(), &request).await? {
        return Ok(file); // ?export=csv, xlsx or print
    }
    let activity = grid().page(Entry::query(), &request).await?;
    Ok(view("activity/index.html", context! { activity }).into_response())
}

GridRequest reads the query string. page applies the filters, the search, the sort and the page to the query you give it, so a grid only ever shows rows that query allows. For client data, that is where an organization’s scope or a staff-only unscoped() goes. The template is one line:

{% from "renox/grid.html" import grid %}
{{ grid(activity) }}

The state lives in the URL

Every filter, search, sort, grouping and page is a query string parameter: ?q.action=login&sort=-created_at&page=2. A filtered view has a link you can bookmark or paste into a chat. The back button works. Reloading the page shows the same rows. The server reads only parameters that belong to the grid’s own filterable columns and ignores the rest, and the query builder checks column names again.

When a filter changes, the grid’s form is submitted with htmx, and the server answers with the page. htmx swaps in only the grid’s element and pushes the new URL. The browser runs htmx and a small grid script, and nothing else: there is no client-side copy of the data and no state to keep in sync.

Actions without an API

A row action is a request to an ordinary route:

Grid::new("instalments")
    // ...columns...
    .row_url("/admin/instalments/{id}")
    .row_action(
        Action::new("Mark paid", "/admin/instalments/{id}/paid")
            .confirm("Record this instalment as paid?"),
    )

The grid asks for confirmation in its own dialog, sends the request with htmx (CSRF token included) and reloads its page when the answer is a success. The handler serves both the grid and a plain form on another page:

pub fn after_action(htmx: &Htmx, toast: Toast, redirect: Redirect) -> Response {
    if htmx.request {
        (toast, StatusCode::NO_CONTENT).into_response() // the grid: a toast, then it reloads
    } else {
        (toast, redirect).into_response()               // a form: back to the project
    }
}

The toast travels in an HX-Trigger header, so a 204 No Content is enough. Actions on a selection work the same way: .bulk_action(…) adds checkboxes, and the handler gets the ids, or “every row matching the filters” when the user selected them all.

More than a table

From the same definition, a grid can also:

  • group rows by a column, with a heading per group (.groups(&["status", "kind"]));
  • add a summary footer over every filtered row, not only the current page (Summary::Sum, Average, Range, Count);
  • open a details row under a row (.details()), or edit cells in place with a PATCH (.editable() + .edit_url(…));
  • refresh itself while the tab is visible (.poll(60)), which suits a queue of incoming requests;
  • show each row as a card on phones (.cards_on_mobile());
  • export CSV, Excel or a print page with the user’s columns and filters.

Charts are SVG, drawn on the server

The portal’s overview page has a stacked bar chart of income per month. The numbers come from a query grouped by month in the database:

let projects = Trend::of(Payment::unscoped().where_eq("kind", PaymentKind::Instalment), "paid_at")
    .over(period)
    .sum(&state, "amount")
    .await?;

The template draws it with {{ chart("bar", income, format="money", stacked=true) }}. The output is plain HTML and SVG, with a “Show the data” table under it for screen readers and for copying the figures. There’s no chart library to download, and the chart is part of the page when it arrives. The admin dashboard in our showcase draws its charts the same way.

What the browser downloads

This back office runs on htmx, Alpine.js, the UI kit’s script and the grid’s script. All of them are served from the app binary itself, under the Content-Security-Policy Renox sends by default. There is no build step for the front end, and for most clicks the only thing that travels is the HTML that changed.

  1. · 3 min read Catching N+1 queries before they ship Renox has no lazy relations on purpose. What it has instead: loaders that fetch a page's related rows in one query each, a debug page that flags repeated SQL, and tests that count queries.
  2. · 3 min read Tests that travel in time How Renox tests a whole app in memory: real requests, a clock you can move forward, and fake HTTP that never reaches the network.
  3. · 4 min read Renox: Laravel's workflow, compiled into one Rust binary What a Renox app is made of: modules, typed handlers, forms that validate themselves, server-rendered views and a CLI that writes the boilerplate.

Get new posts by Atom, RSS or JSON Feed.