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

How to Take Screenshots and Generate PDFs in Clojure

Clojure inherits the JVM's headless browser problem — Selenium needs ChromeDriver, etaoin needs a running browser. Here's the simpler path: one clj-http call, binary response. Fits naturally into Clojure's data-oriented style.

Clojure has no native headless browser. Options on the JVM include Java interop to Selenium WebDriver, calling a Node.js subprocess for Puppeteer, or etaoin (a WebDriver library). All require a running browser and complicate deployment.

Here's the simpler path: one HTTP call, binary response. Fits naturally into Clojure's data-oriented style.

Using clj-http

clj-http is the most common synchronous HTTP client in the Clojure ecosystem:

;; deps.edn
{:deps {clj-http/clj-http {:mvn/version "3.13.0"}
        cheshire/cheshire {:mvn/version "5.13.0"}}}
(ns myapp.pagebolt
  (:require [clj-http.client :as http]
            [cheshire.core :as json]))

(def ^:private base-url "https://pagebolt.dev/api/v1")
(defn- api-key [] (System/getenv "PAGEBOLT_API_KEY"))

(defn screenshot [url & {:keys [full-page? block-banners?]
                          :or {full-page? true block-banners? true}}]
  (:body (http/post (str base-url "/screenshot")
                    {:headers {"x-api-key" (api-key)}
                     :content-type :json
                     :body (json/generate-string {:url url
                                                   :fullPage full-page?
                                                   :blockBanners block-banners?})
                     :as :byte-array})))

(defn pdf-from-url [url]
  (:body (http/post (str base-url "/pdf")
                    {:headers {"x-api-key" (api-key)}
                     :content-type :json
                     :body (json/generate-string {:url url :blockBanners true})
                     :as :byte-array})))

(defn pdf-from-html [html]
  (:body (http/post (str base-url "/pdf")
                    {:headers {"x-api-key" (api-key)}
                     :content-type :json
                     :body (json/generate-string {:html html})
                     :as :byte-array})))
;; Usage
(require '[myapp.pagebolt :as pagebolt])
(require '[clojure.java.io :as io])

(let [image (pagebolt/screenshot "https://example.com")]
  (with-open [out (io/output-stream "screenshot.png")]
    (.write out image)))

Using hato (async, built on Java 11 HttpClient)

;; deps.edn
{:deps {hato/hato {:mvn/version "1.0.0"}
        cheshire/cheshire {:mvn/version "5.13.0"}}}
(ns myapp.pagebolt
  (:require [hato.client :as hato]
            [cheshire.core :as json]))

(def ^:private base-url "https://pagebolt.dev/api/v1")

(defn screenshot [url]
  (:body (hato/post (str base-url "/screenshot")
                    {:headers {"x-api-key" (System/getenv "PAGEBOLT_API_KEY")}
                     :content-type :application/json
                     :body (json/generate-string {:url url :fullPage true :blockBanners true})
                     :as :byte-array})))

Ring handler — PDF download

(ns myapp.handlers.invoices
  (:require [myapp.pagebolt :as pagebolt]
            [myapp.templates :as templates]
            [ring.util.response :as response]))

(defn download-pdf [request]
  (let [id (get-in request [:path-params :id])
        invoice (myapp.invoices/get-by-id id)
        html (templates/render "invoice" {:invoice invoice})
        pdf-bytes (pagebolt/pdf-from-html html)]
    {:status 200
     :headers {"Content-Type" "application/pdf"
               "Content-Disposition" (str "attachment; filename=\"invoice-" id ".pdf\"")}
     :body (java.io.ByteArrayInputStream. pdf-bytes)}))

Compojure route

(ns myapp.routes
  (:require [compojure.core :refer [GET defroutes]]
            [myapp.handlers.invoices :as invoices]))

(defroutes app-routes
  (GET "/invoices/:id/pdf" [id :as request]
    (invoices/download-pdf (assoc-in request [:path-params :id] id))))

Reitit route (Ring + http-kit)

(def routes
  [["/invoices/:id/pdf"
    {:get {:parameters {:path {:id string?}}
           :handler (fn [{{:keys [id]} :path-params}]
                      (let [html (templates/render "invoice" {:id id})
                            pdf  (pagebolt/pdf-from-html html)]
                        {:status 200
                         :headers {"Content-Type" "application/pdf"
                                   "Content-Disposition"
                                   (str "attachment; filename=\"invoice-" id ".pdf\"")}
                         :body (java.io.ByteArrayInputStream. pdf)}))}}]])

With error handling

(defn safe-screenshot [url]
  (try
    {:ok (screenshot url)}
    (catch clojure.lang.ExceptionInfo e
      {:error (ex-message e) :status (:status (ex-data e))})
    (catch Exception e
      {:error (ex-message e)})))

;; Usage
(let [{:keys [ok error]} (safe-screenshot "https://example.com")]
  (if ok
    (spit "screenshot.png" ok)
    (println "Failed:" error)))

No Selenium, no WebDriver, no browser process. clj-http is the idiomatic synchronous HTTP client in Clojure — :as :byte-array returns binary directly, no additional processing needed.

Get Started Free

100 requests/month, no credit card

Screenshots, PDFs, and video recordings from your Clojure app — no Selenium, no browser process, works in Ring, Compojure, Reitit, and http-kit.

Get Your Free API Key →