HTML to PDF in Ruby (and Rails)

HTML to PDF in Ruby (and Rails)

Convert HTML to PDF in Ruby on Rails with wicked_pdf, Grover, Ferrum, or a Net::HTTP or Faraday call to an API, plus a send_data controller.

Rails apps usually generate PDFs, like invoices, receipts and reports, from an ERB view. The view part is easy. The real choice is the renderer.

You can use the long-standing wkhtmltopdf, a headless Chrome driven from Ruby, or a hosted API. This guide compares them with working code and ends with a controller that needs no browser on your server.

Quick answer. POST your HTML to the API and save the bytes. This works from any Ruby app with no gems.

curl -X POST https://html-pdf.com/v1/pdf \
  -H "Authorization: Bearer $HTML_PDF_KEY" \
  -H "Content-Type: application/json" \
  -d '{"html": "<h1>Invoice #042</h1>"}' \
  -o invoice.pdf

Ruby HTML to PDF options

This table shows what each gem renders with and what it needs on your server.

Gem Renders with Modern CSS Requires
wicked_pdf wkhtmltopdf (QtWebKit) Partial, no grid wkhtmltopdf binary (archived project)
Grover Puppeteer + Chrome Yes Node.js, Puppeteer, Chrome
Ferrum Chrome via DevTools protocol, pure Ruby Yes Chrome
Net::HTTP / Faraday + html-pdf.com Headless Chromium, hosted Yes Nothing

Option 1: wicked_pdf

wicked_pdf has been the default Rails answer for years. It plugs into respond_to.

# Gemfile
gem "wicked_pdf"
gem "wkhtmltopdf-binary"
class InvoicesController < ApplicationController
  def show
    @invoice = Invoice.find(params[:id])
    respond_to do |format|
      format.html
      format.pdf do
        render pdf: "invoice-#{@invoice.id}", template: "invoices/show", layout: "pdf",
               page_size: "A4", margin: { top: 15, bottom: 15 }
      end
    end
  end
end

The gem still gets releases, but the engine underneath is the problem. wkhtmltopdf's repository was archived in 2023, its WebKit is years old, and flexbox and grid layouts don't render as they do in a browser.

It's fine for existing table-based templates. For anything new, see our wkhtmltopdf alternative guide.

Option 2: Grover (Puppeteer)

Grover calls Puppeteer from Ruby, so you get Chrome's output.

# Gemfile
gem "grover"
# and: npm install puppeteer
html = render_to_string(template: "invoices/show", layout: "pdf", formats: [:html])
pdf = Grover.new(html, format: "A4", print_background: true,
                       margin: { top: "15mm", bottom: "15mm" }).to_pdf

The trade-off is that every app server or worker now needs Node.js, Puppeteer and Chrome, and each render starts a browser process.

Option 3: Ferrum (pure Ruby, Chrome only)

Ferrum talks to Chrome over the DevTools protocol directly. There's no Node.js, but you still need Chrome installed.

require "ferrum"

browser = Ferrum::Browser.new(timeout: 30)
page = browser.create_page
page.content = "<div style='display:flex;gap:2rem'><h1>Invoice #042</h1></div>"
pdf = page.pdf(encoding: :binary, format: :A4, print_background: true)
File.binwrite("invoice.pdf", pdf)
browser.quit

Ferrum is lean and fast to start. You still own the Chrome install, its memory use under load, and keeping it updated.

Or skip the server: one HTTP call

html-pdf.com runs headless Chromium for you. You send HTML as JSON and get PDF bytes back, with Grover-quality output and only the standard library.

Get a key, then open the link we email you to confirm your address. That adds 100 free credits.

curl -X POST https://html-pdf.com/v1/signup -H "Content-Type: application/json" -d '{"email": "you@example.com"}'

With Net::HTTP (no gems)

require "net/http"
require "json"

module HtmlPdf
  ENDPOINT = URI("https://html-pdf.com/v1/pdf")
  Error = Class.new(StandardError)

  def self.render(html, **options)
    req = Net::HTTP::Post.new(ENDPOINT)
    req["Authorization"] = "Bearer #{ENV.fetch('HTML_PDF_KEY')}"
    req["Content-Type"] = "application/json"
    req.body = { html: html, format: "A4", margin: "15mm", max_pages: 20 }.merge(options).to_json

    res = Net::HTTP.start(ENDPOINT.host, ENDPOINT.port, use_ssl: ENDPOINT.scheme == "https",
                          read_timeout: 60) { |http| http.request(req) }

    unless res.is_a?(Net::HTTPSuccess)
      err = (JSON.parse(res.body)["error"] rescue nil) || {}
      raise Error, "#{res.code} #{err['code']}: #{err['message']}"
    end
    res.body # binary PDF; res["X-Pages"] holds the page count
  end
end

File.binwrite("invoice.pdf", HtmlPdf.render("<h1>Invoice #042</h1>"))

With Faraday

conn = Faraday.new(url: "https://html-pdf.com") do |f|
  f.request :authorization, "Bearer", -> { ENV.fetch("HTML_PDF_KEY") }
  f.request :json
  f.options.timeout = 60
end

res = conn.post("/v1/pdf", { html: html, format: "A4", margin: "15mm" })
raise "PDF failed: #{res.body}" unless res.success?
pdf = res.body

To convert a public page instead, pass url: "https://example.com" instead of html. Add response: "url" to get JSON with a download link valid for 24 hours.

Rails controller with send_data

This controller renders an ERB template, converts it and sends the PDF to the browser.

class InvoicePdfsController < ApplicationController
  def show
    invoice = current_user.invoices.find(params[:invoice_id])
    html = render_to_string(template: "invoices/pdf", layout: "pdf",
                            locals: { invoice: invoice }, formats: [:html])

    pdf = HtmlPdf.render(html,
      footer_html: '<div style="font-size:9px;width:100%;text-align:center">' \
                   'Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>')

    send_data pdf, filename: "invoice-#{invoice.id}.pdf",
                   type: "application/pdf", disposition: "inline"
  rescue HtmlPdf::Error => e
    Rails.logger.error(e.message)
    head :bad_gateway
  end
end
# config/routes.rb
resources :invoices do
  resource :pdf, only: :show, controller: "invoice_pdfs"
end

Use disposition: "attachment" to force a download. ERB's <%= %> escapes output, so customer data can't break the markup.

Assets. The renderer can't reach localhost or private networks, so image_tag and stylesheet_link_tag need absolute public URLs (set config.asset_host). In development, inline CSS in a <style> tag or pass it as css:, and use data URIs for images. You can also send the HTML and its files as multipart (index.html plus assets, with an options JSON field).

For mailers and exports, render inside an Active Job and attach the bytes with Active Storage (attach(io: StringIO.new(pdf), filename: ..., content_type: "application/pdf")).

Credits and errors

Here is what you pay and what can go wrong. 1 credit = 1 PDF page, with 100 free credits and then packs from $10 for 1,000 pages. Purchased credits never expire, failed renders are free, and max_pages stops a runaway document without charge.

Errors come back as JSON {"error": {"code", "message", "hint"}}. A 402 insufficient_credits means it's time to top up.

Limits are 20 MB per request, 30 seconds per render and 120 requests per minute.

More: page breaks · headers, footers and page numbers · HTML to PDF in Python · API docs · sign in

FAQ

How do I convert HTML to PDF in Ruby on Rails?

Render your view with render_to_string, POST the HTML to https://html-pdf.com/v1/pdf, and return the bytes with send_data. Gems like wicked_pdf, Grover and Ferrum also work if you can run their engines on your server.

Is wicked_pdf still a good choice?

It works for existing table-based templates. Its wkhtmltopdf engine was archived in 2023 and doesn't handle flexbox or grid like a modern browser.

Is there a free HTML to PDF API for Ruby?

html-pdf.com gives you 100 free credits once, with no card needed. 1 credit = 1 PDF page, and failed renders cost nothing.

Do credits expire?

No. Purchased credits never expire. Packs go from $10 for 1,000 pages to $250 for 50,000 pages.

This page as markdown · text