How to generate PDFs from HTML in Ruby on Rails
Rails makes it easy to render a view to an HTML string. Turning that string into a good PDF is the hard part. This guide covers Grover, the most common Chromium-based option, explains why WickedPDF is now a legacy choice, and shows how to call PDFOven with nothing but the standard library.
Rendering a view to HTML
Whatever converter you use, start by rendering a dedicated PDF layout:
html = ApplicationController.render(
template: "invoices/show",
layout: "pdf",
assigns: { invoice: invoice }
)
Use a layout that inlines CSS or uses absolute asset URLs. A headless renderer outside the Rails request cycle cannot resolve relative asset paths like /assets/application.css on its own.
Option 1: Grover
Grover wraps Puppeteer, so it renders with real Chromium. Flexbox, grid, web fonts and JavaScript all work.
# Gemfile
gem "grover"
bundle install
npm install puppeteer
pdf = Grover.new(
html,
format: "A4",
print_background: true,
margin: { top: "0.5in", bottom: "0.5in", left: "0.5in", right: "0.5in" },
display_header_footer: true,
footer_template: '<div style="font-size:9px;width:100%;text-align:center">' \
'Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>'
).to_pdf
Grover converts snake_case options to Puppeteer's camelCase. In a controller:
def show
invoice = Invoice.find(params[:id])
html = render_to_string(template: "invoices/show", layout: "pdf", locals: { invoice: invoice })
pdf = Grover.new(html, format: "A4", print_background: true).to_pdf
send_data pdf, filename: "invoice-#{invoice.number}.pdf", type: "application/pdf", disposition: "inline"
end
Trade-offs:
- Your Ruby servers now need Node, Puppeteer and Chromium with its system libraries. On Heroku that means extra buildpacks; in Docker it means a much larger image.
- Each conversion spawns a Node process that launches a browser. That costs hundreds of megabytes and noticeable latency, so move it to a Sidekiq or Solid Queue job for anything user-facing.
- Fonts for non-Latin scripts and emoji must be installed on every machine.
- Timeouts and crashed browsers become your problem to monitor.
A note on WickedPDF
WickedPDF was the default Rails answer for years. It shells out to wkhtmltopdf, whose GitHub repository was archived in 2023 and is no longer maintained. wkhtmltopdf uses an old QtWebKit engine with no flexbox, no CSS grid, and limited modern JavaScript. Existing apps keep working, but it is not a sound choice for new code, and security fixes in the rendering engine are not coming. If you are on WickedPDF today, see our wkhtmltopdf migration guide.
The PDFOven way: Net::HTTP
PDFOven runs Chromium for you. Ruby only needs to send JSON:
require "net/http"
require "json"
def render_pdf(html)
uri = URI("https://pdfoven.com/v1/pdf")
request = Net::HTTP::Post.new(
uri,
"Authorization" => "Bearer #{ENV.fetch('PDFOVEN_KEY')}",
"Content-Type" => "application/json"
)
request.body = {
html: html,
options: { format: "A4", margin: "0.5in", print_background: true }
}.to_json
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https", read_timeout: 60) do |http|
http.request(request)
end
unless response.is_a?(Net::HTTPSuccess)
error = JSON.parse(response.body).fetch("error")
raise "PDFOven #{error['code']}: #{error['message']}"
end
response.body
end
And the controller becomes:
def show
invoice = Invoice.find(params[:id])
html = render_to_string(template: "invoices/show", layout: "pdf", locals: { invoice: invoice })
send_data render_pdf(html),
filename: "invoice-#{invoice.number}.pdf",
type: "application/pdf",
disposition: "inline"
end
No Node, no Chromium, no font packages in your image.
Page numbers and headers
Pass header_template and footer_template inside options. Use <span class="pageNumber"></span> and <span class="totalPages"></span> for numbering, and give the footer an explicit font size since it does not inherit your page styles.
Waiting for JavaScript
If the view draws charts client-side, add an element such as <div id="ready"></div> when rendering finishes and send wait_for_selector: "#ready". You can also cap rendering with timeout_ms.
Saved templates
Instead of rendering ERB, you can store a Handlebars template in PDFOven and send only the data:
request.body = { template_id: "invoice", data: invoice.as_json(include: :line_items) }.to_json
Built-in helpers like formatCurrency, formatDate, sum and lineTotal cover typical invoice math.
Handling limits and errors
Every error is JSON in the form { "error": { "code", "message" } }. A 429 with code quota_exceeded means you have used your monthly renders. The free plan includes 100 renders per month; paid plans are $12, $39 and $99 for 2,000, 15,000 and 60,000. PDFOven does not store rendered files, so persist the bytes yourself (Active Storage works well) if you need to serve them again.
Which to pick
Grover is a good fit if you already run Node alongside Rails and want everything self-hosted. If you want Chromium-quality output without adding a browser to your deploy, a single Net::HTTP call to PDFOven is the smaller change.
Skip the browser plumbing
PDFOven runs Chromium for you. Send HTML, Markdown, a URL or JSON to a template and get a PDF back. 100 free renders every month.
Get a free API key