# 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.
```bash
curl -X POST https://html-pdf.com/v1/pdf \
-H "Authorization: Bearer $HTML_PDF_KEY" \
-H "Content-Type: application/json" \
-d '{"html": "
Invoice #042
"}' \
-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`.
```ruby
# Gemfile
gem "wicked_pdf"
gem "wkhtmltopdf-binary"
```
```ruby
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](https://html-pdf.com/wkhtmltopdf-alternative) guide.
## Option 2: Grover (Puppeteer)
Grover calls Puppeteer from Ruby, so you get Chrome's output.
```ruby
# Gemfile
gem "grover"
# and: npm install puppeteer
```
```ruby
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.
```ruby
require "ferrum"
browser = Ferrum::Browser.new(timeout: 30)
page = browser.create_page
page.content = "Invoice #042
"
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.
```bash
curl -X POST https://html-pdf.com/v1/signup -H "Content-Type: application/json" -d '{"email": "you@example.com"}'
```
### With Net::HTTP (no gems)
```ruby
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("Invoice #042
"))
```
### With Faraday
```ruby
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.
```ruby
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: '' \
'Page of
')
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
```
```ruby
# 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 `