Back to Blog
Guide February 26, 2026 · 4 min read

How to Take Screenshots and Generate PDFs in Rust

Rust has no native headless browser. The usual options — headless_chrome, fantoccini, or shelling out to Puppeteer — all require a running Chrome installation. Here's the simpler path: one HTTP call with reqwest, binary response, no browser.

Rust has no native headless browser. Teams that need screenshots or PDFs typically reach for headless_chrome (a Rust binding for Chrome DevTools Protocol), fantoccini (WebDriver), or shell out to a Puppeteer subprocess. All three require a running Chrome installation and add complexity to deployment.

Here's the simpler path: one HTTP call with reqwest, binary response, no browser.

Dependencies

# Cargo.toml
[dependencies]
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["full"] }
serde_json = "1"

Screenshot from a URL

use reqwest::Client;
use std::env;

async fn screenshot(url: &str) -> Result<Vec<u8>, reqwest::Error> {
    let api_key = env::var("PAGEBOLT_API_KEY").expect("PAGEBOLT_API_KEY not set");
    let client = Client::new();

    let body = serde_json::json!({
        "url": url,
        "fullPage": true,
        "blockBanners": true
    });

    let bytes = client
        .post("https://pagebolt.dev/api/v1/screenshot")
        .header("x-api-key", api_key)
        .json(&body)
        .send()
        .await?
        .bytes()
        .await?;

    Ok(bytes.to_vec())
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let image = screenshot("https://example.com").await?;
    std::fs::write("screenshot.png", &image)?;
    println!("Screenshot saved ({} bytes)", image.len());
    Ok(())
}

PDF from a URL

async fn pdf_from_url(url: &str) -> Result<Vec<u8>, reqwest::Error> {
    let api_key = env::var("PAGEBOLT_API_KEY").expect("PAGEBOLT_API_KEY not set");
    let client = Client::new();

    let bytes = client
        .post("https://pagebolt.dev/api/v1/pdf")
        .header("x-api-key", api_key)
        .json(&serde_json::json!({ "url": url, "blockBanners": true }))
        .send()
        .await?
        .bytes()
        .await?;

    Ok(bytes.to_vec())
}

PDF from HTML

async fn pdf_from_html(html: &str) -> Result<Vec<u8>, reqwest::Error> {
    let api_key = env::var("PAGEBOLT_API_KEY").expect("PAGEBOLT_API_KEY not set");
    let client = Client::new();

    let bytes = client
        .post("https://pagebolt.dev/api/v1/pdf")
        .header("x-api-key", api_key)
        .json(&serde_json::json!({ "html": html }))
        .send()
        .await?
        .bytes()
        .await?;

    Ok(bytes.to_vec())
}

serde_json::json! handles escaping for you — no manual string building.

Reusable client struct

use reqwest::Client;
use std::env;

pub struct PageBolt {
    client: Client,
    api_key: String,
}

impl PageBolt {
    pub fn new() -> Self {
        Self {
            client: Client::new(),
            api_key: env::var("PAGEBOLT_API_KEY").expect("PAGEBOLT_API_KEY not set"),
        }
    }

    pub async fn screenshot(&self, url: &str) -> Result<Vec<u8>, reqwest::Error> {
        let bytes = self.client
            .post("https://pagebolt.dev/api/v1/screenshot")
            .header("x-api-key", &self.api_key)
            .json(&serde_json::json!({ "url": url, "fullPage": true, "blockBanners": true }))
            .send()
            .await?
            .bytes()
            .await?;
        Ok(bytes.to_vec())
    }

    pub async fn pdf_from_url(&self, url: &str) -> Result<Vec<u8>, reqwest::Error> {
        let bytes = self.client
            .post("https://pagebolt.dev/api/v1/pdf")
            .header("x-api-key", &self.api_key)
            .json(&serde_json::json!({ "url": url }))
            .send()
            .await?
            .bytes()
            .await?;
        Ok(bytes.to_vec())
    }

    pub async fn pdf_from_html(&self, html: &str) -> Result<Vec<u8>, reqwest::Error> {
        let bytes = self.client
            .post("https://pagebolt.dev/api/v1/pdf")
            .header("x-api-key", &self.api_key)
            .json(&serde_json::json!({ "html": html }))
            .send()
            .await?
            .bytes()
            .await?;
        Ok(bytes.to_vec())
    }
}

Actix-web handler

use actix_web::{get, web, HttpResponse, Result};

#[get("/invoices/{id}/pdf")]
async fn invoice_pdf(
    path: web::Path<u64>,
    pagebolt: web::Data<PageBolt>,
    invoice_service: web::Data<InvoiceService>,
) -> Result<HttpResponse> {
    let id = path.into_inner();
    let html = invoice_service.render_html(id).await?;

    let pdf = pagebolt
        .pdf_from_html(&html)
        .await
        .map_err(|e| actix_web::error::ErrorInternalServerError(e))?;

    Ok(HttpResponse::Ok()
        .content_type("application/pdf")
        .append_header((
            "Content-Disposition",
            format!("attachment; filename=\"invoice-{}.pdf\"", id),
        ))
        .body(pdf))
}

No browser, no ChromeDriver, no headless_chrome with its ChromeDriver version pinning. The reqwest client used here is the standard choice for async HTTP in Rust — it compiles to a single binary with no external runtime.

Get Started Free

100 requests/month, no credit card

Screenshots, PDFs, and video recordings from your Rust app — no headless_chrome, no Puppeteer subprocess, compiles to a single binary.

Get Your Free API Key →