A Practical Guide to Rocket - Rust's Most Ergonomic Web Framework
A Practical Guide to Rocket - Rust's Most Ergonomic Web Framework
Rocket is the framework that made Rust web development feel ergonomic. While Axum and Actix Web lean on the type system and traits, Rocket leans on macros - and it does so aggressively. Routes, guards, error catchers, and configuration are all driven by attributes and derive macros, which means less boilerplate and more "just write the handler" energy.
Rocket used to require nightly Rust. That changed with v0.5, which runs on stable Rust and adds async support. This tutorial covers Rocket 0.5 - the latest stable release.
We'll build a bookmarks API. CRUD operations, shared state, request guards for authentication, custom error catchers, fairings (Rocket's middleware), and deployment.
Prerequisites
You need Rust installed. If you don't have it yet:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | shProject Setup
Create a new project:
cargo init rocket-tutorial
cd rocket-tutorialAdd the dependencies to Cargo.toml:
[dependencies]
rocket = { version = "0.5", features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4"] }- rocket with the
jsonfeature - the framework plus JSON support via Serde - serde - serialization and deserialization
- uuid - generating unique IDs for bookmarks
Hello World
The simplest Rocket server:
#[macro_use]
extern crate rocket;
#[get("/")]
fn hello() -> &'static str {
"Hello, world!"
}
#[launch]
fn rocket() -> _ {
rocket::build().mount("/", routes![hello])
}A few things to notice:
#[macro_use] extern crate rocketimports Rocket's macros globally. You can also userocket::get,rocket::launch, etc. if you prefer explicit imports.#[get("/")]marks the function as a handler for GET requests to/.#[launch]replaces#[tokio::main]. It sets up the Rocket runtime and launches the server. Under the hood, Rocket uses Tokio.rocket::build()creates a new Rocket instance..mount("/", routes![...])attaches routes at the given base path.- The
routes!macro generates route information from your annotated functions at compile time.
Run it:
cargo runRocket prints a detailed startup log showing every registered route:
Visit http://localhost:8000 and you'll see "Hello, world!". Note the default port is 8000, not 8080.
Routing
Rocket's routing is macro-driven. You annotate functions and Rocket does the rest.
Route Attributes
Each HTTP method has a corresponding attribute:
#[get("/bookmarks")]
fn list_bookmarks() -> &'static str {
"List all bookmarks"
}
#[post("/bookmarks")]
fn create_bookmark() -> &'static str {
"Create a bookmark"
}
#[put("/bookmarks/<id>")]
fn update_bookmark(id: &str) -> String {
format!("Update bookmark {id}")
}
#[delete("/bookmarks/<id>")]
fn delete_bookmark(id: &str) -> String {
format!("Delete bookmark {id}")
}Dynamic segments use angle brackets: <id>. The parameter name must match a function argument. Rocket extracts and converts the value automatically - if id were u32 instead of &str, Rocket would parse it and return a 404 if parsing fails.
Mounting Routes
Routes are attached to a Rocket instance with .mount():
rocket::build().mount("/", routes![
list_bookmarks,
create_bookmark,
update_bookmark,
delete_bookmark,
])The first argument is the base path. If you used "/api" instead of "/", the list endpoint would live at /api/bookmarks.
Multiple Mount Points
You can mount different groups of routes at different paths:
rocket::build()
.mount("/api/v1", routes![list_bookmarks, create_bookmark])
.mount("/api/v2", routes![list_bookmarks_v2])
.mount("/", routes![health])Extractors
Rocket calls them "guards" and "data guards", but the idea is the same as extractors in other frameworks - pull typed data out of the request.
Path Parameters
Path parameters are just function arguments that match <param> in the route:
#[get("/bookmarks/<id>")]
fn get_bookmark(id: &str) -> String {
format!("Bookmark ID: {id}")
}Multiple parameters work the same way:
#[get("/users/<user_id>/bookmarks/<bookmark_id>")]
fn get_user_bookmark(user_id: u32, bookmark_id: &str) -> String {
format!("User {user_id}, Bookmark {bookmark_id}")
}Query Parameters
Use the FromForm derive macro:
use serde::Deserialize;
#[derive(Deserialize, FromForm)]
struct SearchParams {
q: Option<String>,
limit: Option<usize>,
}
#[get("/search?<params..>")]
fn search(params: SearchParams) -> String {
let q = params.q.unwrap_or_default();
let limit = params.limit.unwrap_or(10);
format!("Searching for '{}', limit {}", q, limit)
}The <params..> syntax collects all query parameters into the struct. A request to /search?q=rust&limit=5 extracts both values. Since the fields are Option, the query string is entirely optional.
JSON Body
Use Json<T> as a data guard:
use rocket::serde::json::Json;
use serde::Deserialize;
#[derive(Deserialize)]
struct CreateBookmark {
url: String,
title: String,
tags: Option<Vec<String>>,
}
#[post("/bookmarks", data = "<input>")]
fn create_bookmark(input: Json<CreateBookmark>) -> String {
format!("Created bookmark: {}", input.title)
}The data = "<input>" attribute tells Rocket to parse the request body into the input parameter. If the body isn't valid JSON or doesn't match the struct, Rocket returns a 422 error.
Combining Extractors
You can mix path parameters and data guards in one handler:
#[put("/bookmarks/<id>", data = "<input>")]
fn update_bookmark(id: &str, input: Json<UpdateBookmark>) -> String {
format!("Updating bookmark {id}")
}Path parameters come from the URL, the JSON body comes from the request body. They don't interfere with each other.
Application State
Most APIs need shared state - a database pool, configuration, or in our case, an in-memory bookmark list. Rocket uses State<T> for this.
use rocket::State;
use std::sync::Mutex;
struct AppState {
bookmarks: Mutex<Vec<Bookmark>>,
}
#[get("/bookmarks")]
fn list_bookmarks(state: &State<AppState>) -> Json<Vec<Bookmark>> {
let bookmarks = state.bookmarks.lock().unwrap();
Json(bookmarks.clone())
}Register the state when building the Rocket instance:
rocket::build()
.manage(AppState {
bookmarks: Mutex::new(Vec::new()),
})
.mount("/", routes![list_bookmarks]).manage() adds the state. Rocket wraps it so it's available to any handler that requests &State<AppState>. The Mutex is needed because multiple request handlers can run concurrently.
If you forget to call .manage() for a type that a handler needs, Rocket catches this at startup - not at runtime when the route is hit. It's called a "sentinel" and it's one of Rocket's nicer safety features.
Request Guards
This is where Rocket really shines. Request guards are types that implement FromRequest - they can inspect the incoming request and decide whether the handler should run.
Here's an API key guard:
use rocket::request::{self, FromRequest, Outcome, Request};
use rocket::http::Status;
struct ApiKey(String);
#[rocket::async_trait]
impl<'r> FromRequest<'r> for ApiKey {
type Error = &'static str;
async fn from_request(req: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
match req.headers().get_one("X-API-Key") {
Some(key) if key == "secret-key" => Outcome::Success(ApiKey(key.to_string())),
Some(_) => Outcome::Error((Status::Unauthorized, "Invalid API key")),
None => Outcome::Error((Status::Unauthorized, "Missing API key")),
}
}
}Use it by adding the guard as a parameter to any handler:
#[get("/protected")]
fn protected(_key: ApiKey) -> &'static str {
"You have access!"
}If the X-API-Key header is missing or wrong, the handler never runs. Rocket returns the error status automatically.
The power of this pattern is composability. You can stack multiple guards on a single handler - authentication, rate limiting, feature flags - and each one is a clean, reusable type.
Error Catchers
When Rocket can't find a matching route or a guard fails, it triggers an error catcher. The default catchers return HTML, which isn't great for a JSON API.
Define custom catchers:
use rocket::serde::json::Json;
use serde::Serialize;
#[derive(Serialize)]
struct ErrorResponse {
error: String,
status: u16,
}
#[catch(404)]
fn not_found() -> Json<ErrorResponse> {
Json(ErrorResponse {
error: "Resource not found".to_string(),
status: 404,
})
}
#[catch(422)]
fn unprocessable_entity() -> Json<ErrorResponse> {
Json(ErrorResponse {
error: "Invalid request body".to_string(),
status: 422,
})
}Register them:
rocket::build()
.register("/", catchers![not_found, unprocessable_entity])
.mount("/", routes![...])Now a malformed JSON body returns {"error": "Invalid request body", "status": 422} instead of an HTML page. The #[catch(422)] attribute is important here - 422 is the status Rocket uses when JSON deserialization fails.
Fairings
Fairings are Rocket's version of middleware. They hook into the request/response lifecycle.
Here's a fairing that adds response timing headers:
use rocket::fairing::{Fairing, Info, Kind};
use rocket::Data;
struct RequestTimer;
#[rocket::async_trait]
impl Fairing for RequestTimer {
fn info(&self) -> Info {
Info {
name: "Request Timer",
kind: Kind::Request | Kind::Response,
}
}
async fn on_request(&self, req: &mut rocket::Request<'_>, _data: &mut Data<'_>) {
req.local_cache(|| std::time::Instant::now());
}
async fn on_response<'r>(
&self,
req: &'r rocket::Request<'_>,
res: &mut rocket::Response<'r>,
) {
let start = req.local_cache(|| std::time::Instant::now());
let duration = start.elapsed();
res.set_raw_header("X-Response-Time", format!("{}ms", duration.as_millis()));
}
}Attach it:
rocket::build()
.attach(RequestTimer)
.mount("/", routes![...])The Kind flags control which lifecycle hooks fire. Kind::Request | Kind::Response means this fairing runs on both incoming requests and outgoing responses. There's also Kind::Liftoff (server startup) and Kind::Shutdown.
req.local_cache() stores per-request data. We stash a timestamp on the way in and read it on the way out.
Rocket also ships with a built-in Shield fairing that sets security headers (X-Content-Type-Options, X-Frame-Options, etc.). It's attached by default - you saw it in the startup log.
Putting It All Together
All the pieces - routing, extractors, state, guards, catchers, fairings - compose into a working API. The #[launch] function wires everything together:
#[launch]
fn rocket() -> _ {
let port: u16 = std::env::var("PORT")
.unwrap_or_else(|_| "8000".to_string())
.parse()
.expect("PORT must be a number");
let config = rocket::Config {
port,
address: std::net::Ipv4Addr::UNSPECIFIED.into(),
..rocket::Config::default()
};
rocket::custom(config)
.manage(AppState {
bookmarks: Mutex::new(Vec::new()),
})
.mount(
"/",
routes![
hello,
health,
list_bookmarks,
get_bookmark,
create_bookmark,
update_bookmark,
delete_bookmark,
],
)
}The handlers use &State<AppState> for shared state, Json<T> for request/response bodies, and path parameters for IDs. The Option return type on get_bookmark automatically returns a 404 when the bookmark isn't found.
Testing with curl
Start the server:
cargo runCreate a bookmark:
curl -X POST http://localhost:8000/bookmarks \
-H "Content-Type: application/json" \
-d '{"url": "https://doc.rust-lang.org", "title": "Rust Documentation", "tags": ["rust", "docs"]}'List all bookmarks:
curl http://localhost:8000/bookmarksUpdate a bookmark (replace the ID with the one you got back):
curl -X PUT http://localhost:8000/bookmarks/YOUR_BOOKMARK_ID \
-H "Content-Type: application/json" \
-d '{"title": "The Rust Programming Language"}'Delete a bookmark:
curl -X DELETE http://localhost:8000/bookmarks/YOUR_BOOKMARK_IDRocket vs Axum vs Actix Web
If you're deciding between the three main Rust web frameworks:
| Rocket | Axum | Actix Web | |
|---|---|---|---|
| Style | Macro-heavy, Rails-like | Function-based (Router::new().route()) | Macro-based (#[get("/")]) |
| Runtime | Tokio (built-in) | Pure Tokio | Own runtime (on Tokio) |
| Middleware | Fairings | Tower middleware | Custom middleware system |
| Auth | Request guards (built-in) | Extractors (DIY or tower) | Guards (DIY) |
| Config | Figment (built-in) | Manual | Manual |
| Performance | Good, slightly behind the others | Very fast | Fastest in benchmarks |
Rocket prioritizes developer experience. If you want the most "batteries included" framework with built-in config, security headers, and request guards, Rocket is the pick. If you want raw performance and Tokio ecosystem compatibility, Axum or Actix Web are better choices.
Deploy to Rustfinity Cloud
Your API is working locally. Let's get it live.
The code already reads the PORT environment variable, which is required for Rustfinity Cloud:
let port: u16 = std::env::var("PORT")
.unwrap_or_else(|_| "8000".to_string())
.parse()
.expect("PORT must be a number");
let config = rocket::Config {
port,
address: std::net::Ipv4Addr::UNSPECIFIED.into(),
..rocket::Config::default()
};
rocket::custom(config)Add a Rustfinity.toml to the project root:
[app]
name = "rocket-tutorial"
[build]
command = "cargo build --release"
binary = "target/release/rocket-tutorial"
[health]
path = "/health"
interval = 30Deploy:
rustfinity deployThat's it. Your Rocket API is live on the internet with health checks, zero-downtime deploys, and automatic restarts. See the deployment guide for more details on environment variables, logs, and rollbacks.
What to Learn Next
- Axum Tutorial - the most popular Rust web framework, function-based routing
- Actix Web Tutorial - the fastest Rust web framework, macro-based like Rocket
- Best Rust Web Frameworks - overview and benchmarks of all the major options
- Rocket Documentation - the official guide covers testing, templating, WebSockets, and more
- Rustfinity Challenges - practice Rust fundamentals with hands-on coding challenges
Subscribe to our newsletter
Get the latest updates on courses, features, tools, and resources about Rust.
Learn Rust by Practice
Master Rust through hands-on coding exercises and real-world examples.
Get Started