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 ;
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
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:
new
// ...columns...
.row_url
.row_action
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:
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 aPATCH(.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 = of
.over
.sum
.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.