Web development with Rust is gaining momentum as developers seek alternatives that offer better performance, improved security, and fewer runtime surprises than traditional web stacks. While Rust wasn’t initially designed for web development, its emphasis on safety, speed, and concurrency makes it an excellent fit for modern web applications that need to be reliable and efficient. From low-level HTTP servers to full-stack frameworks, the Rust ecosystem now offers a variety of tools for building web applications at different levels of abstraction.
In this comprehensive guide, we’ll explore the landscape of web development in Rust, from handling HTTP requests to building APIs, rendering templates, and connecting to databases. You’ll learn about the most popular crates and frameworks, understand their strengths and trade-offs, and see how to leverage Rust’s unique features for web development. By the end, you’ll have a solid foundation for building fast, secure web applications in Rust.
The Rust Web Ecosystem
Before diving into specific frameworks, let’s understand the landscape of web development in Rust:
HTTP Libraries
At the foundation are low-level HTTP libraries that handle the protocol details:
- hyper: A fast, correct HTTP implementation
- reqwest: A user-friendly HTTP client built on hyper
- http: Types and traits for HTTP
Web Frameworks
Building on these libraries are web frameworks that provide higher-level abstractions:
- Actix Web: A powerful, pragmatic framework
- Rocket: Focuses on ease of use, developer experience, and type safety
- axum: Modular web framework built on hyper and Tower
- warp: A super-easy, composable web server framework
- tide: A minimal and pragmatic framework
Template Engines
For generating HTML:
- askama: Type-safe, compiled templates inspired by Jinja
- tera: Similar to Jinja2 and Django templates
- handlebars: Logic-less templates
- maud: Compile-time HTML templates using macros
Database Access
For persistence:
- sqlx: SQL toolkit with compile-time checked queries
- diesel: ORM and query builder
- sea-orm: Async ORM built on SQLx
- mongodb: Official MongoDB driver
- redis: Redis client
Authentication and Security
For user management and security:
- jsonwebtoken: JWT implementation
- argon2: Password hashing
- oauth2: OAuth2 client implementation
- rustls: TLS implementation in Rust
Getting Started with a Basic HTTP Server
Let’s start with a simple HTTP server using hyper:
use hyper::service::{make_service_fn, service_fn};
use hyper::{Body, Request, Response, Server};
use std::convert::Infallible;
use std::net::SocketAddr;
async fn handle(_req: Request<Body>) -> Result<Response<Body>, Infallible> {
Ok(Response::new(Body::from("Hello, World!")))
}
#[tokio::main]
async fn main() {
// Define the address to bind to
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
// Create a service from the handle function
let make_svc = make_service_fn(|_conn| async {
Ok::<_, Infallible>(service_fn(handle))
});
// Create and run the server
let server = Server::bind(&addr).serve(make_svc);
println!("Server running on http://{}", addr);
// Wait for the server to complete
if let Err(e) = server.await {
eprintln!("server error: {}", e);
}
}
This example uses the hyper
crate with the tokio
runtime to create a simple HTTP server that responds with “Hello, World!” to all requests.
Building a REST API with Actix Web
Actix Web is one of the most popular Rust web frameworks, known for its performance and flexibility:
use actix_web::{web, App, HttpResponse, HttpServer, Responder};
use serde::{Deserialize, Serialize};
use std::sync::Mutex;
// Define data structures
#[derive(Debug, Serialize, Deserialize, Clone)]
struct User {
id: u32,
name: String,
email: String,
}
// Define application state
struct AppState {
users: Mutex<Vec<User>>,
}
// Handler functions
async fn get_users(data: web::Data<AppState>) -> impl Responder {
let users = data.users.lock().unwrap();
HttpResponse::Ok().json(&*users)
}
async fn get_user(path: web::Path<u32>, data: web::Data<AppState>) -> impl Responder {
let user_id = path.into_inner();
let users = data.users.lock().unwrap();
if let Some(user) = users.iter().find(|u| u.id == user_id) {
HttpResponse::Ok().json(user)
} else {
HttpResponse::NotFound().body("User not found")
}
}
#[derive(Debug, Deserialize)]
struct CreateUser {
name: String,
email: String,
}
async fn create_user(
user: web::Json<CreateUser>,
data: web::Data<AppState>,
) -> impl Responder {
let mut users = data.users.lock().unwrap();
// Generate a new ID (in a real app, this would be more robust)
let new_id = users.len() as u32 + 1;
// Create the new user
let new_user = User {
id: new_id,
name: user.name.clone(),
email: user.email.clone(),
};
// Add to our "database"
users.push(new_user.clone());
HttpResponse::Created().json(new_user)
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
// Initialize application state
let app_state = web::Data::new(AppState {
users: Mutex::new(vec![
User {
id: 1,
name: "Alice".to_string(),
email: "[email protected]".to_string(),
},
User {
id: 2,
name: "Bob".to_string(),
email: "[email protected]".to_string(),
},
]),
});
// Start HTTP server
HttpServer::new(move || {
App::new()
.app_data(app_state.clone())
.route("/users", web::get().to(get_users))
.route("/users/{id}", web::get().to(get_user))
.route("/users", web::post().to(create_user))
})
.bind("127.0.0.1:8080")?
.run()
.await
}
This example creates a simple REST API for managing users with endpoints for listing all users, getting a specific user, and creating a new user.
Type-Safe Routing with Rocket
Rocket emphasizes type safety and developer experience:
#[macro_use] extern crate rocket;
use rocket::serde::{Serialize, Deserialize, json::Json};
use rocket::State;
use std::sync::Mutex;
// Define data structures
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(crate = "rocket::serde")]
struct User {
id: u32,
name: String,
email: String,
}
// Define application state
struct AppState {
users: Mutex<Vec<User>>,
}
// Handler functions
#[get("/users")]
fn get_users(state: &State<AppState>) -> Json<Vec<User>> {
let users = state.users.lock().unwrap();
Json(users.clone())
}
#[get("/users/<id>")]
fn get_user(id: u32, state: &State<AppState>) -> Option<Json<User>> {
let users = state.users.lock().unwrap();
users.iter()
.find(|u| u.id == id)
.map(|user| Json(user.clone()))
}
#[derive(Debug, Deserialize)]
#[serde(crate = "rocket::serde")]
struct CreateUser {
name: String,
email: String,
}
#[post("/users", data = "<user>")]
fn create_user(user: Json<CreateUser>, state: &State<AppState>) -> Json<User> {
let mut users = state.users.lock().unwrap();
// Generate a new ID
let new_id = users.len() as u32 + 1;
// Create the new user
let new_user = User {
id: new_id,
name: user.name.clone(),
email: user.email.clone(),
};
// Add to our "database"
users.push(new_user.clone());
Json(new_user)
}
#[launch]
fn rocket() -> _ {
// Initialize application state
let app_state = AppState {
users: Mutex::new(vec![
User {
id: 1,
name: "Alice".to_string(),
email: "[email protected]".to_string(),
},
User {
id: 2,
name: "Bob".to_string(),
email: "[email protected]".to_string(),
},
]),
};
rocket::build()
.manage(app_state)
.mount("/", routes![get_users, get_user, create_user])
}
Rocket’s approach is more declarative, with routes defined using attributes and strong typing for parameters and responses.
HTML Templates with Askama
For server-side rendering, Askama provides type-safe templates:
use actix_web::{web, App, HttpResponse, HttpServer};
use askama::Template;
// Define a template
#[derive(Template)]
#[template(path = "index.html")]
struct IndexTemplate {
title: String,
users: Vec<User>,
}
// Define data structures
#[derive(Debug, Clone)]
struct User {
id: u32,
name: String,
email: String,
}
// Handler function
async fn index() -> HttpResponse {
let users = vec![
User {
id: 1,
name: "Alice".to_string(),
email: "[email protected]".to_string(),
},
User {
id: 2,
name: "Bob".to_string(),
email: "[email protected]".to_string(),
},
];
let template = IndexTemplate {
title: "User List".to_string(),
users,
};
// Render the template
match template.render() {
Ok(html) => HttpResponse::Ok().content_type("text/html").body(html),
Err(_) => HttpResponse::InternalServerError().finish(),
}
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| {
App::new()
.route("/", web::get().to(index))
})
.bind("127.0.0.1:8080")?
.run()
.await
}
The corresponding template file (templates/index.html
) would look like:
<!DOCTYPE html>
<html>
<head>
<title>{{ title }}</title>
<style>
table {
border-collapse: collapse;
width: 100%;
}
th, td {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
th {
background-color: #f2f2f2;
}
</style>
</head>
<body>
<h1>{{ title }}</h1>
<table>
<tr>
<th>ID</th>
<th>Name</th>
<th>Email</th>
</tr>
{% for user in users %}
<tr>
<td>{{ user.id }}</td>
<td>{{ user.name }}</td>
<td>{{ user.email }}</td>
</tr>
{% endfor %}
</table>
</body>
</html>
Database Access with SQLx
SQLx provides compile-time checked SQL queries:
use sqlx::{postgres::PgPoolOptions, Pool, Postgres};
use std::env;
// Define data structures
#[derive(Debug, sqlx::FromRow)]
struct User {
id: i32,
name: String,
email: String,
}
async fn create_user_table(pool: &Pool<Postgres>) -> Result<(), sqlx::Error> {
sqlx::query(
"CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
)"
)
.execute(pool)
.await?;
Ok(())
}
async fn insert_user(pool: &Pool<Postgres>, name: &str, email: &str) -> Result<User, sqlx::Error> {
let user = sqlx::query_as::<_, User>(
"INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id, name, email"
)
.bind(name)
.bind(email)
.fetch_one(pool)
.await?;
Ok(user)
}
async fn get_user_by_id(pool: &Pool<Postgres>, id: i32) -> Result<Option<User>, sqlx::Error> {
let user = sqlx::query_as::<_, User>("SELECT id, name, email FROM users WHERE id = $1")
.bind(id)
.fetch_optional(pool)
.await?;
Ok(user)
}
async fn get_all_users(pool: &Pool<Postgres>) -> Result<Vec<User>, sqlx::Error> {
let users = sqlx::query_as::<_, User>("SELECT id, name, email FROM users")
.fetch_all(pool)
.await?;
Ok(users)
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Load environment variables from .env file
dotenv::dotenv().ok();
// Get database URL from environment
let database_url = env::var("DATABASE_URL")
.expect("DATABASE_URL must be set");
// Create connection pool
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(&database_url)
.await?;
// Create table if it doesn't exist
create_user_table(&pool).await?;
// Insert a user
let user = insert_user(&pool, "Alice", "[email protected]").await?;
println!("Inserted user: {:?}", user);
// Get a user by ID
let user = get_user_by_id(&pool, 1).await?;
println!("User with ID 1: {:?}", user);
// Get all users
let users = get_all_users(&pool).await?;
println!("All users: {:?}", users);
Ok(())
}
Authentication with JWT
JSON Web Tokens (JWT) are commonly used for authentication in web applications:
use actix_web::{web, App, HttpResponse, HttpServer, Responder};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
// Define JWT claims
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
sub: String, // Subject (user ID)
exp: usize, // Expiration time
iat: usize, // Issued at
}
// Define login request
#[derive(Debug, Deserialize)]
struct LoginRequest {
username: String,
password: String,
}
// Define login response
#[derive(Debug, Serialize)]
struct LoginResponse {
token: String,
}
// JWT secret key (in a real app, this would be loaded from environment or config)
const JWT_SECRET: &[u8] = b"my_secret_key";
// Handler functions
async fn login(req: web::Json<LoginRequest>) -> impl Responder {
// In a real app, you would validate credentials against a database
if req.username == "admin" && req.password == "password" {
// Create JWT claims
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Time went backwards")
.as_secs() as usize;
let claims = Claims {
sub: req.username.clone(),
exp: now + 3600, // Token expires in 1 hour
iat: now,
};
// Encode JWT
let token = encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(JWT_SECRET),
)
.unwrap();
HttpResponse::Ok().json(LoginResponse { token })
} else {
HttpResponse::Unauthorized().body("Invalid credentials")
}
}
async fn protected(req: web::HttpRequest) -> impl Responder {
// Extract JWT from Authorization header
let auth_header = match req.headers().get("Authorization") {
Some(header) => header.to_str().unwrap_or(""),
None => return HttpResponse::Unauthorized().body("No authorization header"),
};
if !auth_header.starts_with("Bearer ") {
return HttpResponse::Unauthorized().body("Invalid authorization header");
}
let token = &auth_header[7..]; // Remove "Bearer " prefix
// Validate JWT
let token_data = match decode::<Claims>(
token,
&DecodingKey::from_secret(JWT_SECRET),
&Validation::default(),
) {
Ok(data) => data,
Err(_) => return HttpResponse::Unauthorized().body("Invalid token"),
};
// Return protected data
HttpResponse::Ok().json(format!("Hello, {}! This is protected data.", token_data.claims.sub))
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| {
App::new()
.route("/login", web::post().to(login))
.route("/protected", web::get().to(protected))
})
.bind("127.0.0.1:8080")?
.run()
.await
}
Best Practices for Rust Web Development
Based on experience from large Rust web projects, here are some best practices:
1. Choose the Right Framework for Your Needs
- Actix Web: Great for high-performance applications with complex requirements
- Rocket: Excellent for developer experience and rapid development
- axum: Good for modular applications built on the Tower ecosystem
- warp: Ideal for composable, functional API design
- tide: Suitable for simple, straightforward applications
2. Use Strong Types for Request and Response Data
// Define request and response types
#[derive(Deserialize)]
struct CreateUserRequest {
name: String,
email: String,
}
#[derive(Serialize)]
struct UserResponse {
id: u32,
name: String,
email: String,
created_at: chrono::DateTime<chrono::Utc>,
}
// Use them in your handlers
async fn create_user(
req: web::Json<CreateUserRequest>,
) -> Result<web::Json<UserResponse>, Error> {
// Process the request...
// Return a strongly-typed response
}
3. Handle Errors Consistently
use thiserror::Error;
#[derive(Error, Debug)]
enum ApiError {
#[error("Not found")]
NotFound,
#[error("Unauthorized")]
Unauthorized,
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
#[error("Validation error: {0}")]
Validation(String),
}
// Implement responder for your error type
impl actix_web::ResponseError for ApiError {
fn error_response(&self) -> HttpResponse {
match self {
ApiError::NotFound => HttpResponse::NotFound().json("Not found"),
ApiError::Unauthorized => HttpResponse::Unauthorized().json("Unauthorized"),
ApiError::Database(e) => HttpResponse::InternalServerError().json(e.to_string()),
ApiError::Validation(msg) => HttpResponse::BadRequest().json(msg),
}
}
}
4. Use Connection Pooling for Databases
// Create a connection pool once
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(&database_url)
.await?;
// Share it with your application
let app_state = web::Data::new(AppState { db: pool });
// Use it in handlers
async fn get_users(state: web::Data<AppState>) -> Result<impl Responder, Error> {
let users = sqlx::query_as::<_, User>("SELECT * FROM users")
.fetch_all(&state.db)
.await?;
Ok(web::Json(users))
}
5. Test Your API Endpoints
#[cfg(test)]
mod tests {
use super::*;
use actix_web::{test, App};
#[actix_rt::test]
async fn test_get_users() {
// Create app with test services
let app = test::init_service(
App::new()
.app_data(web::Data::new(create_test_state().await))
.route("/users", web::get().to(get_users))
).await;
// Create a test request
let req = test::TestRequest::get().uri("/users").to_request();
// Execute the request and check the response
let resp = test::call_service(&app, req).await;
assert!(resp.status().is_success());
// Parse the response body
let body = test::read_body(resp).await;
let users: Vec<User> = serde_json::from_slice(&body).unwrap();
assert!(!users.is_empty());
}
}
Conclusion
Rust’s web ecosystem has matured significantly in recent years, offering a compelling alternative to traditional web development stacks. With its focus on safety, performance, and concurrency, Rust is well-suited for building modern web applications that need to be reliable and efficient.
The key takeaways from this exploration of web development in Rust are:
- Multiple frameworks are available to suit different needs and preferences
- Type safety is a core strength, catching errors at compile time
- Performance is excellent, with frameworks like Actix Web among the fastest in any language
- Safety guarantees extend to web applications, preventing common vulnerabilities
- Async support enables efficient handling of many concurrent connections
Whether you’re building a simple API, a complex web application, or a high-performance service, Rust provides the tools and libraries you need to succeed. As the ecosystem continues to evolve, web development in Rust will only become more accessible and powerful, making it an excellent choice for your next web project.