PDFPipe

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.

toml
[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.

rust
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.

rust
#[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.

rust
.json(&json!({
    "url": "https://example.com/report/42",
    "options": { "format": "A4" }
}))

Why not a headless browser crate?

ConcernHeadless browser cratereqwest to PDFPipe
Binary in imageChromium, ~300 MB addedNone
Static / musl buildsComplicated, dynamic deps on glibcUnaffected
Memory per renderA full browser tabA response body
Compile timeLarge crate graph, slower buildsreqwest 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 →