DEV Community

Custodia-Admin
Custodia-Admin

Posted on • Originally published at pagebolt.dev

How to take screenshots and generate PDFs in Elixir

How to Take Screenshots and Generate PDFs in Elixir

Elixir has no native headless browser. Teams that need screenshots or PDFs typically shell out to a wkhtmltopdf binary, run a Node.js Puppeteer subprocess, or use chrome_remote_interface — a Chromium binding that requires a running Chrome instance. None of these are easy to supervise in an OTP application.

Here's the simpler path: one HTTP call, binary response. Works in any Mix project or Phoenix endpoint.

Using Req (recommended)

Req is the modern Elixir HTTP client. Add it to mix.exs:

defp deps do
  [
    {:req, "~> 0.5"}
  ]
end
Enter fullscreen mode Exit fullscreen mode
defmodule PageBolt do
  @base_url "https://pagebolt.dev/api/v1"

  defp api_key, do: System.fetch_env!("PAGEBOLT_API_KEY")

  def screenshot(url) do
    Req.post!(
      "#{@base_url}/screenshot",
      headers: [{"x-api-key", api_key()}],
      json: %{url: url, fullPage: true, blockBanners: true}
    ).body
  end

  def pdf_from_url(url) do
    Req.post!(
      "#{@base_url}/pdf",
      headers: [{"x-api-key", api_key()}],
      json: %{url: url, blockBanners: true}
    ).body
  end

  def pdf_from_html(html) do
    Req.post!(
      "#{@base_url}/pdf",
      headers: [{"x-api-key", api_key()}],
      json: %{html: html}
    ).body
  end
end
Enter fullscreen mode Exit fullscreen mode

Req handles JSON encoding automatically when you pass json: — no manual Jason.encode! needed.

Using HTTPoison

If your project already uses HTTPoison:

defmodule PageBolt do
  @base_url "https://pagebolt.dev/api/v1"

  defp headers do
    [
      {"x-api-key", System.fetch_env!("PAGEBOLT_API_KEY")},
      {"Content-Type", "application/json"}
    ]
  end

  def screenshot(url) do
    body = Jason.encode!(%{url: url, fullPage: true, blockBanners: true})
    {:ok, %{body: data}} = HTTPoison.post("#{@base_url}/screenshot", body, headers())
    data
  end

  def pdf_from_html(html) do
    body = Jason.encode!(%{html: html})
    {:ok, %{body: data}} = HTTPoison.post("#{@base_url}/pdf", body, headers())
    data
  end
end
Enter fullscreen mode Exit fullscreen mode

Phoenix controller — PDF download

defmodule MyAppWeb.InvoiceController do
  use MyAppWeb, :controller

  def download_pdf(conn, %{"id" => id}) do
    invoice = MyApp.Invoices.get!(id)
    html = Phoenix.View.render_to_string(MyAppWeb.InvoiceView, "show.html", invoice: invoice)

    pdf = PageBolt.pdf_from_html(html)

    conn
    |> put_resp_content_type("application/pdf")
    |> put_resp_header("content-disposition", ~s(attachment; filename="invoice-#{id}.pdf"))
    |> send_resp(200, pdf)
  end
end
Enter fullscreen mode Exit fullscreen mode

Phoenix LiveView — on-demand screenshot

defmodule MyAppWeb.DashboardLive do
  use MyAppWeb, :live_view

  def handle_event("export_screenshot", _params, socket) do
    url = MyAppWeb.Router.Helpers.dashboard_url(socket, :show)
    image = PageBolt.screenshot(url)

    # Save to object storage or return as download
    {:ok, path} = MyApp.Storage.upload(image, "dashboard-#{Date.utc_today()}.png")

    {:noreply, assign(socket, :screenshot_url, path)}
  end
end
Enter fullscreen mode Exit fullscreen mode

Supervised GenServer wrapper

For production use, wrap the client in a GenServer to manage the API key and add retry logic:

defmodule MyApp.PageBolt do
  use GenServer

  def start_link(_opts) do
    GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
  end

  def screenshot(url), do: GenServer.call(__MODULE__, {:screenshot, url})
  def pdf_from_html(html), do: GenServer.call(__MODULE__, {:pdf_html, html})

  @impl true
  def init(_) do
    {:ok, %{api_key: System.fetch_env!("PAGEBOLT_API_KEY")}}
  end

  @impl true
  def handle_call({:screenshot, url}, _from, %{api_key: key} = state) do
    result = Req.post!(
      "https://pagebolt.dev/api/v1/screenshot",
      headers: [{"x-api-key", key}],
      json: %{url: url, fullPage: true, blockBanners: true}
    ).body
    {:reply, result, state}
  end

  @impl true
  def handle_call({:pdf_html, html}, _from, %{api_key: key} = state) do
    result = Req.post!(
      "https://pagebolt.dev/api/v1/pdf",
      headers: [{"x-api-key", key}],
      json: %{html: html}
    ).body
    {:reply, result, state}
  end
end
Enter fullscreen mode Exit fullscreen mode

Add to your supervision tree:

# application.ex
children = [
  MyApp.Repo,
  MyAppWeb.Endpoint,
  MyApp.PageBolt   # ← add this
]
Enter fullscreen mode Exit fullscreen mode

No wkhtmltopdf binary, no Node.js subprocess, no Chrome process to supervise. Req ships as a pure-Elixir dependency — one entry in mix.exs, no system packages.


Try it free — 100 requests/month, no credit card. → Get started in 2 minutes

Top comments (0)