If you've ever used Django, you know the feeling: one command, and you have an admin panel, an ORM, form validation, middleware, session handling — everything just works. Then you try Rust web development, and you're back to assembling pieces yourself.
That's why I built Runique: a batteries-included web framework for Rust, inspired by Django's philosophy, built on top of Axum and Tokio.
Why "Django for Rust"?
Existing Rust frameworks are excellent at what they do — Axum is fast and composable, Actix-Web is a performance beast — but they're low-level by design. You bring your own ORM, your own session store, your own CSRF protection, your own admin interface. For many projects, that's the right tradeoff.
Runique takes the opposite bet: convention over configuration, with a structured, opinionated setup that gets you from zero to a production-ready app fast, without sacrificing Rust's safety and performance.
The Builder: A Validated Construction Pipeline
The part I'm most proud of is the application builder. You declare your components with a fluent API — in any order — and then a single .build() call runs a fixed, validated construction pipeline at startup:
Validation → DB connection → Templates → Engine → Admin → Middleware → Static files
Here's what a typical app setup looks like:
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
password_init(PasswordConfig::auto_with(Manual::Argon2));
set_lang(Lang::En);
let config: RuniqueConfig = RuniqueConfig::from_env();
let db_config = DatabaseConfig::from_env()?.build();
let db: DatabaseConnection = db_config.connect().await?;
builder::new(config)
.with_log(|l| {
l.dev()
.host_validation(tracing::Level::WARN)
.acme(tracing::Level::INFO)
})
.routes(url::routes())
.with_database(db)
.with_mailer_from_env()
.with_password_reset::<BuiltinUserEntity>(|pr| {
pr.forgot_template("auth/forgot_password.html")
.reset_template("auth/reset_password.html")
})
.statics()
.middleware(|m| {
m.with_session_memory_limit(5 * 1024 * 1024, 10 * 1024 * 1024)
.with_session_cleanup_interval(5)
.with_allowed_hosts(|h| {
h.enabled(!is_debug())
.host("runique.io")
.host("www.runique.io")
.host("localhost:3000")
.host("127.0.0.1:3000")
})
.with_csp(|c| {
c.policy(SecurityPolicy::strict())
.with_header_security(true)
.with_upgrade_insecure(!is_debug())
.scripts(vec!["'self'", "'strict-dynamic'"])
})
})
.with_admin(|a| {
a.site_title("Administration")
.sitemap("https://runique.io/sitemap.xml")
.auth(RuniqueAdminAuth::new())
.routes(admins::routes("/admin-runique/"))
.templates(|t| t.with_dashboard("admin/test_dashboard.html"))
.with_state(admins::admin_state())
.with_rate_limiter(RateLimiter::new().max_requests(20).retry_after(3600))
.with_login_guard(LoginGuard::new().max_attempts(20).lockout_secs(300))
.page_size(15)
})
.build()
.await
.map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })?
.run()
.await?;
Ok(())
}
The .build() call validates every component — including cross-dependencies — before constructing anything. If your SECRET_KEY is still the default insecure value in production, the build fails with a clear error and a fix suggestion, before a single request is served:
[Security] SECRET_KEY is using the default insecure value
→ Set SECRET_KEY to a random 32+ character string in your .env file
This is the Django check framework equivalent, but enforced at startup — not discovered in production.
Security Included by Default
Security in Runique isn't a plugin you add later. It's part of the construction pipeline itself:
- CSP (Content Security Policy) — configurable profiles, per-request nonce, HTMX hash merging when the admin panel is enabled
- CSRF protection — built into the middleware stack, with per-route exemptions
- Host validation — allowed hosts checked before routing
- Trusted proxies — explicit configuration, not implicit trust
-
Security headers on static files —
X-Content-Type-Options,Strict-Transport-Security,X-Frame-Options,Referrer-Policyapplied automatically
// You don't wire ServeDir yourself — `.statics()` does it,
// already wrapping every static/media route with security headers:
{% static "path" %}
// → X-Content-Type-Options: nosniff
// Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
// X-Frame-Options: DENY
// Referrer-Policy: strict-origin-when-cross-origin
// + cache-control
The middleware stack uses numbered slots to guarantee application order — you can't accidentally apply CSRF before sessions, because the slots enforce the correct sequence.
What's Included
- ORM (via SeaORM, optional feature flag)
- Template engine (Tera, with custom filters and a URL registry for named routes)
- Admin panel (auto-generated from your models, merged before the middleware stack so it always has session/auth context)
- Form engine (typed structs with validation, v2 in progress)
-
Session handling (memory-first store with database fallback, built on
tower-sessions, with periodic cleanup) - i18n (the FR/EN bilingual doc site is itself built with Runique)
- Middleware system with ordered slots
- Password reset (pluggable, routes registered automatically during build)
- Debug error page — a styled page with collapsible sections and copy-to-clipboard for stack traces
One Concrete Example: Admin Merge Order
Here's a subtle design decision that illustrates Runique's philosophy. In Axum, .layer() only applies to routes present at call time. This means if you merge your admin router after applying middleware, the admin routes run without session, CSRF, or extension context — a silent, hard-to-debug failure.
Runique handles this in the builder:
// Step 4b: admin + password reset — merged BEFORE the middleware stack.
// `.layer()` in Axum only covers routes present at call time;
// merging after means admin routes run without Session/CSRF/Extensions.
let router = if self.admin.enabled {
router.merge(admin_router)
} else {
router
};
// Step 5: middleware applied AFTER, covering everything including admin
let (router, session_store) = middleware.apply_to_router(router, config, engine, tera);
You don't have to think about this. The pipeline handles it.
Current Status
Runique is at v2.1.x, MIT licensed, with bilingual documentation (EN/FR) at runique.io.
The framework is used in production for a restaurant management system — dogfooding at its most direct.
What's missing (being honest here, Django-style):
- No async task queue yet (background jobs run on bare
tokio::spawn) - Email sending is partial
- OAuth is planned but not yet implemented
- Test coverage is at ~61% (growing)
Try It
Add the dependency to your Cargo.toml:
[dependencies]
runique = "2.1"
Read the Getting Started Guide
Links & Resources
seb-alliot
/
runique
A framework web base on Django/python
Runique — the Django developer experience, in type-safe Rust
Declare a model once — get the database table, the migration, a type-safe form and a full admin panel. Runique is a batteries-included web framework that brings Django's productivity to Rust, without giving up Rust's safety and performance. Built on Axum, SeaORM and Tera.
Status — honest: active development. The framework crate (
runique) is the source of truth;demo-appis a real validation app exercised against it. The admin is in beta. Nothing below is overstated — see Project status.
🌍 Languages: English | Français
Declarative macros, not boilerplate
model! {
Article,
table: "articles",
pk: id => Pk,
enums: { Status: [Draft="Draft", Published="Published"], },
{
title: text [required],
slug: text [unique],…- Documentation: runique.io
- Crates.io: crates.io/crates/runique
What do you think?
I'm particularly curious to hear from:
- Django developers — Does this structure feel familiar, or "too much" for the Rust ecosystem?
- Rust experts — How would you handle the middleware ordering differently?
Let's discuss in the comments!
Top comments (4)
Nice project. Well done.
I've programmed a lot with Django 4 and it's never given me any problems. A very reliable framework. I hope yours has as much luck as Django. Good work.
Thanks! The project isn't finished yet, but it's progressing :)
The ORM layer is always the trickiest part in these frameworks. Did you consider embedding the schema directly in the struct definitions? Rust's type system makes it actually feasible at compile time.
I'm curious about the routing - Axum's tower-web patterns or something custom?
Great questions — both hit the core design decisions.
On the ORM / schema-in-structs: that's exactly the direction Runique took, just one layer above a plain derive. The schema lives in a derive_form!{} DSL that's parsed at compile time (via syn) and generates two things: the SeaORM entities and the SQL migrations.
The reason it's a small DSL rather than annotations on a plain Rust struct is that native Rust types don't carry enough information to be the single source of truth. A String field doesn't tell you its max_length, whether it's unique, or whether it's a slug or rich text; an i32 foreign key doesn't tell you its target table or its on-delete behavior; a file upload needs size/extension constraints. So fields are declared with semantic types — text, fk(Table), enum(Name), file, decimal — and the macro lowers them into engine-specific SQL (Postgres gets real ENUM + triggers, MariaDB gets ON UPDATE, SQLite gets its own shape). SeaORM stays underneath as the runtime query layer — I'm not reinventing that part, Runique just owns the declaration + migration generation on top.
So: compile-time, type-checked, one definition → entities + migrations. The tradeoff versus a pure #[derive] on a struct is that you give up "it's just a normal Rust struct" in exchange for multi-engine migration generation that a plain struct's types can't express.
And that's exactly where the proc-macro is headed next: being able to drive the SeaORM column type finely, field by field — exposing SeaORM's column_type directly in the DSL for cases where the default semantic type isn't enough (exact decimal precision, a specific native SQL type, a custom mapping). The goal is to keep the semantic types as the ergonomic default while offering an escape hatch down to SeaORM's precise typing when you need it — without ever having to leave the DSL to hand-edit an entity.
Happy to go deeper on either if you're curious. The DSL is documented here if you want to see the full field syntax: runique.io/docs/en/model/dsl
On routing: it's custom, built directly on Axum 0.8's Router rather than tower-web. There's a urlpatterns!{} macro, deliberately Django-flavored — typed path params (/menu/{id:}), named routes for reverse lookups, etc. — that expands into ordinary Axum handlers.
By default the view!{ handler } helper wires a single handler onto all methods (GET/POST/PUT/DELETE/PATCH), which is handy for getting moving fast. If you want to split GET from POST, you declare it explicitly with the usual Axum combinators (get(...).post(...)): the separation is fully possible, it's just opt-in rather than forced. Same idea for per-route protections: login_required, rate_limit, etc. attach fluently on top of the router.
Tower still does the heavy lifting on the middleware side: Runique layers a strict slot ordering on top (compression → error handling → CSP → session → CSRF → host validation → handler) so the security middleware can't accidentally end up in the wrong order.
Happy to go deeper on either if you're curious.