Guide
Generate a PDF from HTML in Rust
You have an Axum or Actix handler, some HTML (an invoice, a statement, a certificate), and you need to return a PDF. Driving a headless browser from Rust means spawning a Chrome process and keeping it alive, or pulling in a Chromium wrapper crate that brings its own binary. This guide shows the simpler path: one reqwest call, streamed straight back to the client.
Dependencies
Add these to your Cargo.toml. Everything here is already in a typical async Rust service.
[dependencies]
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
axum = "0.7"The Axum handler
The PDFPipe endpoint is POST https://api.pdfpipe.xyz/v1/pdf. Send a JSON body with your HTML and options, attach a bearer token, and you get back raw application/pdf bytes.
use axum::{
extract::{Path, State},
http::{header, StatusCode},
response::{IntoResponse, Response},
};
use reqwest::Client;
use serde_json::json;
use std::env;
#[derive(Clone)]
pub struct AppState {
pub http: Client,
pub pdfpipe_key: String,
}
pub async fn invoice_handler(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Response {
let html = format!(
r#"<html>
<body style="font-family: sans-serif; padding: 40px">
<h1>Invoice #{id}</h1>
<p>Amount due: $420.00</p>
</body>
</html>"#
);
let result = state
.http
.post("https://api.pdfpipe.xyz/v1/pdf")
.bearer_auth(&state.pdfpipe_key)
.json(&json!({
"html": html,
"options": { "format": "A4" }
}))
.send()
.await;
let resp = match result {
Ok(r) => r,
Err(e) => {
eprintln!("PDFPipe request failed: {e}");
return (StatusCode::BAD_GATEWAY, "PDF render failed").into_response();
}
};
if !resp.status().is_success() {
let detail = resp.text().await.unwrap_or_default();
return (StatusCode::BAD_GATEWAY, detail).into_response();
}
let bytes = match resp.bytes().await {
Ok(b) => b,
Err(e) => {
eprintln!("Failed to read PDF bytes: {e}");
return (StatusCode::BAD_GATEWAY, "PDF render failed").into_response();
}
};
(
StatusCode::OK,
[
(header::CONTENT_TYPE, "application/pdf"),
(
header::CONTENT_DISPOSITION,
&format!(r#"inline; filename="invoice-{id}.pdf""#),
),
],
bytes,
)
.into_response()
}Wiring it up
Share one reqwest::Client across all requests so the connection pool is reused. Read the key from an environment variable at startup.
#[tokio::main]
async fn main() {
let pdfpipe_key = env::var("PDFPIPE_API_KEY")
.expect("PDFPIPE_API_KEY must be set");
let state = AppState {
http: Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.unwrap(),
pdfpipe_key,
};
let app = axum::Router::new()
.route("/invoice/:id", axum::routing::get(invoice_handler))
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}The handler streams the bytes directly from the API response into the Axum response body. No allocation for the full PDF in memory beyond what Axum buffers before flushing.
Rendering from a URL
If the document is already hosted at a URL, pass that instead of inlining HTML.
.json(&json!({
"url": "https://example.com/report/42",
"options": { "format": "A4" }
}))Why not a headless browser crate?
| Concern | Headless browser crate | reqwest to PDFPipe |
|---|---|---|
| Binary in image | Chromium, ~300 MB added | None |
| Static / musl builds | Complicated, dynamic deps on glibc | Unaffected |
| Memory per render | A full browser tab | A response body |
| Compile time | Large crate graph, slower builds | reqwest only, already likely present |
Paste your own HTML into the live playground on the home page to see the PDF render instantly, then read the docs for every option.
See pricing →