
July 2, 2026
Rust is a strong fit for small JSON APIs that need to stay lean without sacrificing correctness. Its type system helps catch entire classes of bugs at compile time, while its zero-cost abstractions and efficient serialization ecosystem make it a practical choice for services that must remain fast under load. Serde is the de facto serialization framework in Rust, and it provides compile-time derive macros for Serialize and Deserialize, which makes turning Rust structs into JSON payloads straightforward. (serde.rs)
In this guide, we’ll build a tiny JSON API that is intentionally minimal, but still structured for real-world use. We’ll cover framework choice, request and response modeling, safe JSON parsing, test strategy, and the production hardening you should not skip. The goal is not to create a sprawling microservice platform. The goal is to create a service that is easy to read, easy to test, and easy to deploy, while leaving enough room to grow when requirements change.

A tiny JSON API is one of the best places to use Rust because the service shape is simple, but the operational requirements are often unforgiving. You want predictable latency, low memory overhead, and a codebase that does not degrade as more endpoints are added. Rust’s compile-time guarantees help with memory safety and thread safety, and its ecosystem encourages explicit data modeling instead of loosely typed request handling. Serde’s trait-based design allows JSON serialization and deserialization to be optimized aggressively by the compiler, which is one reason Rust APIs often remain fast even when they are small and heavily structured. (docs.rs)
For maintainability, Rust shines because your API contracts are encoded in types. A request body is not just “some JSON”; it is a struct with named fields, validation constraints, and explicit optionality. This reduces ambiguity for both backend developers and API consumers. If you later add new fields, rename values, or introduce nested objects, the compiler and tests help reveal breakage early. That is especially useful for services that begin as a single-file prototype but quickly become shared infrastructure for internal teams or external clients.
Performance is another major reason to choose Rust. JSON APIs are often bottlenecked by parsing, allocation, and request handling overhead. Rust frameworks like Axum and Actix Web build on async runtimes and efficient request extractors, allowing you to keep the service minimal without sacrificing throughput. More importantly, Rust encourages you to think carefully about ownership, cloning, and buffering, which can prevent accidental inefficiencies that are easy to introduce in dynamically typed stacks. In practice, a tiny Rust JSON API is often “production-ready” much earlier than its equivalent in many other languages because the compile-time guarantees are already doing real work. (docs.rs)
For a minimal JSON service, the two most common modern choices are Axum and Actix Web. Both are solid, both are async, and both support clean JSON handling. The decision usually comes down to style and ecosystem fit rather than raw capability. Axum is built around the Tower ecosystem and emphasizes a macro-free, composable service model. Its documentation highlights routing via handlers and integration with tower and tower-http, which makes it appealing if you want middleware and service composition to feel very explicit. (docs.rs)
Actix Web, on the other hand, is highly mature and built around its extractor model. Its documentation describes request information extraction as type-safe and notes that handlers can accept up to 12 extractors. For JSON APIs, Actix’s web::Json<T> makes parsing request bodies ergonomic, and it also exposes JsonConfig for extraction options. That makes Actix especially attractive if you want a very direct “request in, typed payload out” programming model. (actix.rs)
If your priority is a tiny service with a clean mental model and a strong Tower middleware story, Axum is an excellent default. If your priority is a framework with a long track record and a very convenient extractor-based handler style, Actix Web is equally defensible. For this article, I’ll favor Axum in examples because its JSON extractor behavior is explicit, and it fits naturally with small, layered APIs. But the architecture patterns apply to both frameworks. In both cases, your JSON handling relies on Serde, and your request/response types should remain shared, typed, and testable. (docs.rs)

cargo new, Dependencies, and Rust Edition ChoicesStart with a normal binary crate:
cargo new tiny-json-api
cd tiny-json-apiFor a minimal production service, I recommend using the latest stable Rust toolchain and a modern edition. Rust 2024 is the newest edition and is documented as part of the edition update process, while older editions remain supported. If you prefer maximum ecosystem conservatism, Rust 2021 is still a sensible default; if you want the most forward-looking setup, use 2024. The important thing is not the edition alone, but that you keep the toolchain current and compatible with the crates you choose. (blog.rust-lang.org)
A typical Cargo.toml for Axum might look like this:
[package]
name = "tiny-json-api"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = { version = "0.8", features = ["json"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tower-http = { version = "0.6", features = ["cors", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
[dev-dependencies]
reqwest = { version = "0.12", features = ["json"] }Serde’s derive feature is the key dependency for model definition, because it generates Serialize and Deserialize implementations at compile time. That is the foundation of a typed JSON API. Axum’s JSON extractor and response type rely on those traits, and Tokio provides the async runtime. (serde.rs)
A small but important note: keep dependencies minimal. Resist the temptation to add a validation framework, ORM, and API documentation stack on day one. A tiny API should start with only the pieces you actually need. That keeps compilation times low, reduces the surface area for bugs, and makes the code easier to reason about.
The most important design decision in a JSON API is the shape of your types. Don’t start from routes; start from data. Define request and response structs that capture the API contract clearly. Serde’s derive macros make this straightforward: you attach #[derive(Serialize, Deserialize)] to a struct, and the compiler generates the trait implementations for you. That is the intended and documented approach for common Rust data structures. (serde.rs)
Here is a small example:
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
pub struct CreateNoteRequest {
pub title: String,
pub body: String,
}
#[derive(Debug, Serialize)]
pub struct NoteResponse {
pub id: u64,
pub title: String,
pub body: String,
}This pattern keeps the request and response contracts separate. That is useful because APIs often need to expose fields in responses that clients should not send, such as generated IDs, timestamps, or derived metadata. It also lets you evolve input and output independently. For example, you might accept a CreateNoteRequest with required title/body fields, but return a NoteResponse with server-generated fields like id and created_at.
You can use Serde attributes to customize behavior when needed. Examples include renaming fields to snake_case or camelCase, making fields optional, or enforcing defaults. But for a tiny API, keep the initial model simple and explicit. The more your data model mirrors your business logic, the less conversion code you need later. That matters because the cleanest APIs are often the ones where the domain model is easy to see directly in the type definitions.
If you need to support unknown or flexible shapes, Serde can handle that too, but be careful. Typed APIs are easiest to maintain when the schema is narrow and intentional. In a production service, explicit request models often make validation and error handling much more predictable.
Once your models are defined, the first endpoint should be trivial to read. A tiny API usually needs at least one GET route for health or listing resources, and one POST route for creating data. Axum’s Json<T> extractor/response type is designed for exactly this. When used as an extractor, it deserializes request bodies into a type that implements Serde deserialization, and when used as a response, it serializes any Serialize type into JSON and sets Content-Type: application/json. (docs.rs)
Example:
use axum::{
extract::Json,
routing::{get, post},
Router,
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
struct CreateNoteRequest {
title: String,
body: String,
}
#[derive(Debug, Serialize)]
struct NoteResponse {
id: u64,
title: String,
body: String,
}
async fn health() -> Json<serde_json::Value> {
Json(serde_json::json!({ "status": "ok" }))
}
async fn create_note(Json(payload): Json<CreateNoteRequest>) -> Json<NoteResponse> {
let response = NoteResponse {
id: 1,
title: payload.title,
body: payload.body,
};
Json(response)
}
fn app() -> Router {
Router::new()
.route("/health", get(health))
.route("/notes", post(create_note))
}A GET /health endpoint is useful for load balancers, container health checks, and simple smoke tests. A POST /notes endpoint shows the basic JSON flow: the client sends JSON, Axum deserializes it into a Rust struct, and the handler returns a typed response that becomes JSON automatically. Because the response type is explicit, it is very easy to see what the client will receive.
This is also where you begin to appreciate Rust’s “small surface area” philosophy. There is little magic here. The handler parameters express the request contract, and the return type expresses the response contract. That makes onboarding easier for other developers and reduces hidden control flow.
Accepting JSON is not the same as accepting valid business input. A request can be syntactically valid JSON and still be wrong for your application. For example, a title may be empty, a count may be negative, or a string field may exceed a reasonable length. Axum’s JSON extractor rejects requests for invalid content type, invalid JSON syntax, deserialization failures, and body buffering failures, which gives you a solid baseline. (docs.rs)
However, you still need domain validation. A practical approach is to validate immediately after deserialization:
use axum::{
extract::Json,
http::StatusCode,
response::IntoResponse,
};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct CreateNoteRequest {
title: String,
body: String,
}
async fn create_note(
Json(payload): Json<CreateNoteRequest>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
if payload.title.trim().is_empty() {
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "title must not be empty"
})),
));
}
Ok((
StatusCode::CREATED,
Json(serde_json::json!({
"id": 1,
"title": payload.title,
"body": payload.body
})),
))
}This pattern is simple and effective. Use 400 Bad Request for malformed or invalid client input, 422 Unprocessable Entity when the payload is syntactically correct but fails semantic validation, and 201 Created for successful creation. In a tiny API, it is often better to keep the error shape consistent, for example:
{ "error": "title must not be empty" }That uniformity is valuable because client-side code becomes easier to write and test. If you later want richer validation errors, add a dedicated error type rather than scattering ad hoc strings throughout your handlers.
Actix Web follows a similar philosophy with its web::Json<T> extractor and JsonConfig for extraction behavior. The exact handler style differs, but the same rule applies: parse into types first, then validate business rules explicitly, then return status codes that accurately represent what happened. (docs.rs)
The fastest way to regret a tiny API is to leave everything in main.rs. A service with one or two routes can live there briefly, but once you add validation, state, testing, or background tasks, you should split the code into modules. The goal is not complexity; the goal is separation of concerns.
A simple layout might look like this:
src/
main.rs
app.rs
routes/
mod.rs
notes.rs
models/
mod.rs
notes.rs
errors.rs
config.rsThis structure keeps HTTP concerns, business models, and configuration separate. models/ should hold request and response types. routes/ should hold handlers. config.rs should parse environment variables and runtime settings. errors.rs should define application-specific error conversions, so your handlers do not need to manually translate every failure into a response.
Routing should remain declarative. In Axum, the router itself becomes the map of the application:
use axum::{routing::{get, post}, Router};
pub fn router() -> Router {
Router::new()
.route("/health", get(crate::routes::health))
.route("/notes", post(crate::routes::create_note))
}If you need shared state later, introduce it deliberately using State<AppState> rather than global variables. That makes testing easier and keeps behavior explicit. Likewise, if you need configuration values such as port numbers, DB URLs, or feature flags, parse them once at startup and inject them into the app state.
A tiny API grows best when the architecture is boring. Keep the module tree shallow, keep type names clear, and avoid over-abstracting early. Rust makes it easy to build a strong structure without making the code verbose, so use that advantage to your benefit.

curl ExamplesTesting is where a minimal API becomes trustworthy. Start with unit tests for pure logic such as validation rules and model transformations. These tests should not involve the network. If a title is empty, if a value is out of bounds, or if a formatter turns a domain object into a response object, test that directly.
Then add integration tests for the HTTP layer. In Rust, integration tests usually live in tests/ and exercise the app as a running service or a testable router. For a JSON API, the most valuable checks are:
GET /health returns 200 OK and valid JSON
POST /notes returns 201 Created
invalid payloads return 400 or 422
response bodies match the expected schema
A simple curl session is also helpful, both as documentation and as a debugging tool:
curl -i http://localhost:3000/healthcurl -i -X POST http://localhost:3000/notes \
-H 'Content-Type: application/json' \
-d '{"title":"First note","body":"Hello from Rust"}'curl -i -X POST http://localhost:3000/notes \
-H 'Content-Type: application/json' \
-d '{"title":"","body":"Missing title"}'The last request should fail with a client error. That is an important signal: validation is not just a backend concern. It is part of the public contract of your API.
If you are using Axum, a common testing strategy is to construct the router in a reusable app() function and test it directly. That avoids needing to bind a real socket for every test, which keeps the test suite fast. If you are using Actix Web, the extractor system also makes it convenient to test handler behavior at a fairly fine granularity. The main objective is the same in both frameworks: keep HTTP responses stable and predictable as your code evolves.
A service is not production-ready just because it responds to JSON. Production hardening is where a tiny API becomes operationally sane. Start with logging. Use structured logs so each request can be traced by path, status, and latency. In the Rust ecosystem, tracing is the standard choice, and middleware from tower-http can help instrument request/response flow in Axum-based services. (docs.rs)
Next, add CORS only if you need browser access from another origin. Don’t enable wide-open cross-origin access by default. Instead, define allowed origins, methods, and headers explicitly. For server-to-server JSON APIs, you often don’t need permissive CORS at all.
Request limits matter too. A tiny API can still be taken down by oversized payloads if you let clients send arbitrarily large JSON bodies. Set body size limits and reasonable timeouts. Both are defensive measures that protect memory and help you fail fast when a client misbehaves.
You should also think about observability from day one, even if you keep it basic. At minimum, measure:
request count
request latency
error rate
payload rejection rate
These metrics help answer simple but vital questions: Is the service alive? Are requests slowing down? Are clients sending malformed JSON? Even lightweight observability is enough to make debugging much easier.
Finally, return clear error messages without exposing internal details. A small API often leaks implementation specifics if errors are not normalized. Keep internal stack traces out of client responses, log them server-side, and return a stable JSON error schema to consumers. That is one of the easiest ways to make a tiny service feel professionally maintained.
Deployment for a tiny Rust API should also be tiny. A multi-stage Docker build is usually the simplest path: compile the binary in a builder image, then copy the release binary into a slim runtime image. This keeps your production artifact small and avoids shipping the entire Rust toolchain.
A production container also benefits from:
running as a non-root user
exposing only the required port
using environment variables for configuration
setting a restart policy in your orchestrator
For hosting, any platform that can run a Linux container will work: a VM, a managed container platform, or Kubernetes if you truly need it. Most tiny JSON APIs do not need Kubernetes at first. A single container or small app service is often more than enough.
OpenAPI is the next practical step once your service becomes a shared contract. Even a small API benefits from generated or hand-written documentation because it makes client integration much easier. While Axum and Actix Web do not require OpenAPI tooling, the ecosystem has common add-ons that can generate specs from typed routes and models. If you already modeled your request and response payloads with Serde, you are halfway there.
A thoughtful next-step roadmap looks like this:
Keep the API surface narrow.
Add request validation for all non-trivial inputs.
Introduce automated tests before adding more routes.
Add observability before traffic grows.
Document the contract once it stabilizes.
The nice thing about Rust is that these incremental improvements usually do not require a rewrite. The language and ecosystem encourage careful construction from the beginning, which makes the “tiny but production-ready” path realistic rather than aspirational.
A tiny JSON API in Rust works well because Rust aligns with the core needs of an API service: correctness, performance, and clarity. Serde gives you a clean data model story, Axum and Actix Web provide ergonomic request handling, and Rust’s type system helps keep the implementation honest as the service grows. (serde.rs)
The key takeaway is that “tiny” should not mean fragile. Start with explicit request and response types, validate input immediately, structure the code into small modules, and add logging, limits, and tests early. If you do that, your API will stay fast and readable while still being ready for real production use.