DEV Community

Cover image for Building a Production REST API in Rust with Express-Like Simplicity
Somen's 8D world
Somen's 8D world

Posted on AI-assisted

Building a Production REST API in Rust with Express-Like Simplicity

Building a Production REST API in Rust with Express-Like Simplicity

Building a Production REST API in Rust with Express Simplicity

If you come from the JavaScript (Express/Fastify) or Python (FastAPI/Flask) worlds, you are used to building APIs at the speed of thought. You define a route, parse JSON from the body, extract URL parameters, and return a response.

When web developers transition to traditional Rust web frameworks like Axum or Actix, they often hit a brick wall. Routing requires managing complex extractor tuples (Path, Query, Json, State), and one misplaced generic type triggers compiler errors that look like cryptographic puzzles.

In this guide, we will build a complete, production-ready CRUD REST API in Rust in under 15 minutes using Nitrox—without fighting trait bounds or lifetime errors.


What We Are Building

A complete User Management API supporting:

  • GET /api/users — List all users
  • GET /api/users/:id — Fetch user by ID
  • POST /api/users — Create user with JSON validation
  • PUT /api/users/:id — Update existing user
  • DELETE /api/users/:id — Remove user
  • Built-in logging, CORS headers, and standard HTTP error handling

Step 1: Project Setup

Create a new Rust binary project:

cargo new user-api --bin
cd user-api
Enter fullscreen mode Exit fullscreen mode

Open Cargo.toml and add the following dependencies:

[dependencies]
nitrox = "0.0.1"
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
uuid = { version = "1.0", features = ["v4"] }
Enter fullscreen mode Exit fullscreen mode

That’s all you need. Nitrox bundles Hyper, Tokio, and matchit routing under the hood.


Step 2: Define Your Data Models

In src/main.rs, define your data structures using Serde:

use nitrox::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
    pub id: Option<String>,
    pub name: String,
    pub email: String,
    #[serde(default)]
    pub role: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct CreateUserDto {
    pub name: String,
    pub email: String,
    pub role: Option<String>,
}
Enter fullscreen mode Exit fullscreen mode

Notice how clean this is. No framework-specific magic traits—just standard Rust structs.


Step 3: Writing the Express-Style Routes

Now let’s build the application. Look how familiar this syntax is:

use nitrox::prelude::*;
use uuid::Uuid;

#[tokio::main]
async fn main() -> Result<()> {
    let app = Nitrox::new();

    // 1. Global Middleware
    app.use_middleware(logger());
    app.use_middleware(cors("*"));

    // 2. Health Check
    app.get("/health", |_req, res| async move {
        res.json(json!({ "status": "healthy", "uptime": "ok" }))
    });

    // 3. GET /api/users - List Users
    app.get("/api/users", |_req, res| async move {
        let sample_users = vec![
            json!({ "id": "u-101", "name": "Alice Johnson", "email": "alice@company.com" }),
            json!({ "id": "u-102", "name": "Bob Smith", "email": "bob@company.com" }),
        ];
        res.json(json!({ "success": true, "data": sample_users }))
    });

    // 4. GET /api/users/:id - Fetch by ID
    app.get("/api/users/:id", |req, res| async move {
        let user_id = req.param("id").unwrap_or_default();

        if user_id.is_empty() {
            return Ok(res.bad_request("User ID is required"));
        }

        res.json(json!({
            "id": user_id,
            "name": "Alice Johnson",
            "email": "alice@company.com"
        }))
    });

    // 5. POST /api/users - Create User with JSON Parsing
    app.post("/api/users", |req, res| async move {
        match req.json::<CreateUserDto>() {
            Ok(payload) => {
                let new_user = json!({
                    "id": Uuid::new_v4().to_string(),
                    "name": payload.name,
                    "email": payload.email,
                    "role": payload.role.unwrap_or_else(|| "user".to_string())
                });

                res.status(201).json(json!({
                    "message": "User created successfully",
                    "data": new_user
                }))
            }
            Err(err) => res.bad_request(&format!("Invalid JSON payload: {}", err)),
        }
    });

    // 6. PUT /api/users/:id - Update User
    app.put("/api/users/:id", |req, res| async move {
        let user_id = req.param("id").unwrap_or_default();

        match req.json::<CreateUserDto>() {
            Ok(payload) => res.json(json!({
                "message": "User updated",
                "id": user_id,
                "data": payload
            })),
            Err(err) => res.bad_request(&format!("Malformed input: {}", err)),
        }
    });

    // 7. DELETE /api/users/:id - Remove User
    app.delete("/api/users/:id", |req, res| async move {
        let user_id = req.param("id").unwrap_or_default();
        res.json(json!({
            "message": "User deleted",
            "id": user_id
        }))
    });

    // Start server
    println!("🚀 Server listening on http://127.0.0.1:3000");
    app.listen(3000).await
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Testing Your Endpoints

Start your server:

cargo run
Enter fullscreen mode Exit fullscreen mode

Your server compiles in seconds and listens on port 3000. Open a new terminal and test it with curl:

Create a User:

curl -X POST http://localhost:3000/api/users \
  -H "Content-Type: application/json" \
  -d '{"name": "Devin Roy", "email": "devin@example.com", "role": "admin"}'
Enter fullscreen mode Exit fullscreen mode

Response:

{
  "message": "User created successfully",
  "data": {
    "id": "e932b1f8-9844-42b4-8e10-91c6e42bca81",
    "name": "Devin Roy",
    "email": "devin@example.com",
    "role": "admin"
  }
}
Enter fullscreen mode Exit fullscreen mode

Fetch by ID:

curl http://localhost:3000/api/users/e932b1f8
Enter fullscreen mode Exit fullscreen mode

What Makes This Different?

Compare what you just wrote with other Rust frameworks:

  1. No Extractor Hell: In Axum, mixing up the order of (Path, Json) vs (Json, Path) in handler arguments causes compilation to fail with a wall of trait error messages. In Nitrox, you simply inspect req.param("id") and call req.json::<T>().
  2. Predictable Fluent Responses: Chain .status(201).header("X-Custom", "Val").json(...) naturally, exactly like Express or Fastify.
  3. No Lifetime Gymnastics: You don't have to define references with 'a or manage complex boxing manually.

You get the safety and 150k req/sec speed of native compiled Rust, with the development velocity of Node.js.


What’s Next?

Tomorrow in Day 3, we will look at the real-world economics of replacing heavy Node.js containers with Nitrox:
"Our AWS Bill Dropped 80%: Replacing Heavy Node.js Containers with a 15MB Rust Binary"

⭐ Star Nitrox on GitHub: https://github.com/KTBsomen/nitrox

Top comments (0)