HTML to PDF in Ruby on Rails

The usual Rails PDF gems put a browser engine on your server: wicked_pdf drives wkhtmltopdf, Grover drives Puppeteer and Chrome. This guide keeps your ERB views and render_to_string, and sends the HTML to an API that prints it with Chrome. Every snippet below was run on Rails 7.2.3 and Ruby 3.3.8 (Ubuntu 26.04) against the live API, and we checked the PDFs it returned.

Quick answer

OptionWhat it needs on your server
wicked_pdf
wkhtmltopdf (archived in 2023, 2012-era WebKit)
The wkhtmltopdf binary. Ubuntu 26.04 has no wkhtmltopdf package (apt-cache policy: no candidate), and the wkhtmltopdf-binary gem 0.12.6.10 is a 451.8 MB download (RubyGems, 2026-10-22).
Grover
Puppeteer from Ruby
Node, the puppeteer npm package and a Chrome it downloads, on every web or job server.
Net::HTTP + API
Chrome on someone else's server
Nothing: Net::HTTP and JSON ship with Ruby. Modern CSS (flexbox, grid, web fonts) works because it's current Chrome.

1. Plain Ruby

No gems. Set SHOTMINT_KEY (free key) and run it:

require "net/http"
require "json"

uri = URI("https://shotmint.creasework.com/v1/pdf")
req = Net::HTTP::Post.new(uri, "X-API-Key" => ENV.fetch("SHOTMINT_KEY"), "Content-Type" => "application/json")
req.body = {
  html: "<h1 style='font-family:sans-serif'>Hello from Ruby</h1>",
  format: "A4",
  margin: "15mm"
}.to_json

res = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 60) { |h| h.request(req) }
raise "Render failed (#{res.code}): #{res.body}" unless res.is_a?(Net::HTTPSuccess)
File.binwrite("hello.pdf", res.body)
puts "Saved hello.pdf (#{res.body.bytesize} bytes)"

A bad or missing key raises with the API's message, e.g. Render failed (401): {"error":"Missing or invalid API key. ...","get_key":...}, so you never write an error body into a .pdf file.

2. A Rails service object

app/services/shotmint_pdf.rb. Extra options (landscape, margin, page_numbers, header_template ...) pass straight through:

require "net/http"
require "json"

class ShotmintPdf
  ENDPOINT = URI("https://shotmint.creasework.com/v1/pdf")
  class Error < StandardError; end

  def self.call(html, **options)
    req = Net::HTTP::Post.new(ENDPOINT, "X-API-Key" => ENV.fetch("SHOTMINT_KEY"), "Content-Type" => "application/json")
    req.body = { html: html, format: "A4", print_background: true, **options }.to_json
    res = Net::HTTP.start(ENDPOINT.host, ENDPOINT.port, use_ssl: true, open_timeout: 5, read_timeout: 60) { |h| h.request(req) }
    raise Error, "Shotmint #{res.code}: #{res.body}" unless res.is_a?(Net::HTTPSuccess)
    res.body
  end
end

3. The controller: same view, .pdf format

/invoices/1042 shows the HTML page and /invoices/1042.pdf the PDF. Rails already knows the pdf MIME type, so respond_to needs no setup. formats: [:html] makes render_to_string pick show.html.erb while the request format is pdf:

class InvoicesController < ApplicationController
  def show
    @invoice = Invoice.find(params[:id])
    respond_to do |format|
      format.html
      format.pdf do
        html = render_to_string(:show, layout: "pdf", formats: [:html])
        send_data ShotmintPdf.call(html, page_numbers: true),
                  filename: "invoice-#{@invoice.number}.pdf",
                  type: "application/pdf", disposition: "inline"
      end
    end
  end
end

In our test this returned 200, application/pdf, inline; filename="invoice-INV-1042.pdf": a one-page A4 PDF with the flexbox header, the table and "1 / 1" at the bottom, in 0.14 s for the whole request. ERB escaping still applies, so a customer named Ünïcode & Co <script> printed as text, not markup.

4. The stylesheet trap (and the layout that avoids it)

Your normal layout links CSS with stylesheet_link_tag, which writes a relative path like /assets/application-3f2a.css. When you send HTML (not a URL), the renderer has no host to resolve that path against, so the stylesheet is skipped without an error. We measured it: the same HTML with a relative <link rel="stylesheet"> came back in Liberation Serif, the browser default (pdffonts); with the CSS reachable it came back in the stylesheet's sans-serif font.

The fix is a small PDF layout that inlines the CSS. app/views/layouts/pdf.html.erb:

<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <%# The renderer can't resolve /assets/... paths, so inline the CSS %>
  <style><%= Rails.root.join("app/assets/stylesheets/pdf.css").read.html_safe %></style>
</head>
<body><%= yield %></body>
</html>

with app/assets/stylesheets/pdf.css:

body { font-family: "DejaVu Sans", sans-serif; color: #222; }
.head { display: flex; justify-content: space-between; border-bottom: 3px solid #4f46e5; }
table { width: 100%; border-collapse: collapse; }
td, th { padding: 6px; border-bottom: 1px solid #ddd; text-align: left; }
.total { background: #eef2ff; font-weight: bold; }

and the view app/views/invoices/show.html.erb:

<div class="head"><h1>Invoice <%= @invoice.number %></h1><p><%= @invoice.customer %></p></div>
<table>
  <tr><th>Item</th><th>Amount</th></tr>
  <% @invoice.lines.each do |name, cents| %>
    <tr><td><%= name %></td><td><%= number_to_currency(cents / 100.0, unit: "€") %></td></tr>
  <% end %>
  <tr class="total"><td>Total</td><td><%= number_to_currency(@invoice.lines.sum { _2 } / 100.0, unit: "€") %></td></tr>
</table>

If your stylesheets and images are on a public host, there's a second fix: add <base href="https://your-app.example/"> in the PDF layout's head (or send "base_url": "https://your-app.example/" alongside html and the API adds it for you) and the relative paths resolve (tested: the font switched to the stylesheet's). Pages on localhost or behind a login can't be fetched by the API, so inline CSS is the safer default. For images, use absolute public URLs or data: URIs.

Background jobs and attachments

ShotmintPdf.call returns the PDF bytes, so in a job you can attach them instead of sending them: invoice.pdf.attach(io: StringIO.new(bytes), filename: "invoice.pdf", content_type: "application/pdf"). Render the HTML with ApplicationController.render(template: "invoices/show", layout: "pdf", assigns: { invoice: invoice }), since jobs have no controller.

Moving off wicked_pdf

wicked_pdfHere
render pdf: "invoice"send_data ShotmintPdf.call(render_to_string(...))
wicked_pdf_stylesheet_link_tagInline <style> in the PDF layout (section 4)
footer: { right: "[page] of [topage]" }page_numbers: true, or footer_template with <span class="pageNumber">
orientation: "Landscape", margin: { top: 10 }landscape: true, margin: "10mm" (always give a unit)

CSS that wkhtmltopdf ignored (flexbox gaps, grid, break-inside) now works, so check old workarounds like float layouts. More in the wkhtmltopdf alternative guide.

What it costs

One PDF = one credit. €5 buys 2,000 renders, credits never expire and there's no subscription. Try your HTML in the free HTML to PDF tool first, then see the API docs for every option.

Try it with 25 free renders a month

No signup, no card. Get a key in one click, then buy credits only if you need more (from €5 for 2,000, never expire). From a script, an AI agent, Make or Zapier, POST /v1/keys/free (no body) returns a key as JSON (openapi.json, no-code recipes).

Get a free API key Full API reference

More guides: HTML to PDF and HTML to image

All guides: guides · integrations · comparisons