Renox is the framework this site runs on. It takes the shape of a Laravel app (routes, models, migrations, validation, queues, mail, a scheduler, generators) and builds it on Rust: Axum for HTTP, MiniJinja for templates, SQLite or PostgreSQL for data, htmx and Alpine.js in the browser. Everything comes in one renox dependency, and everything compiles into one executable.
This post walks through what an app is made of, with code from this site and from Renox’s examples.
The app is a value
A Renox app is built in a function, app(), as a value of type App. main.rs only runs it, and every test boots the same value:
That is this whole site. An app with accounts adds .module(Auth::new()), one more line. There’s no service container to configure and no files discovered at runtime. If a module isn’t in that list, it isn’t in the app, and the compiler checks the list.
A feature is a module
Each feature lives in src/app/<name>/ and implements Module: a name and its routes, and optionally its own migrations, jobs, event listeners and scheduled tasks. This blog is the whole pattern (a few routes left out):
;
Layers and guards such as .etag(), .require_auth() or .require_permission(…) cover the routes added before them in that Routes. Once you know that rule, the order of calls is all there is to it. rnx route:list prints every route with its name, its module and its guards, so you can check the result.
Handlers are typed functions
A handler is an async function whose arguments are extractors. If an argument can’t be built (no session, a missing row, invalid input), the handler never runs. Here is one from an admin area that changes a user’s roles:
async
The return type is also the response. (Toast, HxRefresh) shows a toast and tells htmx to reload the page; (Toast, Redirect) flashes the toast and redirects. Every combination like this is checked when the code compiles.
Forms validate themselves
Rules sit on the form’s fields, written the way Laravel writes them:
That is this site’s contact form.
When rules depend on each other, or need the database, you implement Validate by hand with three hooks: prepare tidies the input, authorize answers 403 before any rule runs, and after checks the database and puts its errors on a field. The handler only ever sees valid data.
Views are rendered on the server
Templates are MiniJinja (Jinja2’s syntax), with a UI kit of macros for forms, tables, sheets, toasts and data grids:
{% from "renox/ui.html" import input, textarea, button %}
{{ csrf_field() }}
{{ input("email", "Email", type="email", required=true, autocomplete="email") }}
{{ textarea("message", "Message", rows=6, required=true) }}
{{ button("Send message") }}
The kit labels every field for screen readers, refills the fields after a failed post and shows each error under its field. A plain post that fails comes back as a redirect to the form, with the errors and the input flashed. An htmx request gets a 422 with the errors as JSON instead, and the bundled renox.js puts each one next to its input and focuses the first, without resetting what was typed. data-live-validate checks fields as people leave them. None of this takes JavaScript of your own, and a Content-Security-Policy is on by default.
The CLI writes the boilerplate
rnx is Renox’s command-line tool. Instead of writing a CRUD by hand, you generate one, tests included:
The generated code is plain Rust in your app. There’s no runtime magic behind it, so you can read it, change it or delete it.
One binary
rnx build compiles the views, translations, CSS, JavaScript and migrations into the executable. A release is that file plus a .env: no Node build on the server, no Redis, no separate worker process (the queue and the scheduler run inside serve). The first post here covers why that, together with SQLite, makes pages fast.
Where to start
The tutorial builds an app from rnx new to a deploy. If you know Laravel, the Laravel guide maps each concept to its Renox equivalent. The next posts here go deeper: tests that travel in time, catching N+1 queries, deploys that refuse no connections, and an admin panel without a JavaScript framework.