rustrocketapibackendtutorial

A Practical Guide to Rocket - Rust's Most Ergonomic Web Framework

ยท10 min read

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 | sh

Project Setup

Create a new project:

cargo init rocket-tutorial
cd rocket-tutorial

Add 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 json feature - 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 rocket imports Rocket's macros globally. You can also use rocket::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 run

Rocket 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 run

Create 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/bookmarks

Update 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_ID

Rocket vs Axum vs Actix Web

If you're deciding between the three main Rust web frameworks:

RocketAxumActix Web
StyleMacro-heavy, Rails-likeFunction-based (Router::new().route())Macro-based (#[get("/")])
RuntimeTokio (built-in)Pure TokioOwn runtime (on Tokio)
MiddlewareFairingsTower middlewareCustom middleware system
AuthRequest guards (built-in)Extractors (DIY or tower)Guards (DIY)
ConfigFigment (built-in)ManualManual
PerformanceGood, slightly behind the othersVery fastFastest 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 = 30

Deploy:

rustfinity deploy

That'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

Subscribe to our newsletter

Get the latest updates on courses, features, tools, and resources about Rust.

Ferris the Rust crab

Learn Rust by Practice

Master Rust through hands-on coding exercises and real-world examples.

Get Started