Rails PDF generation from HTML
Rails has had one dominant answer for a decade: wicked_pdf plus
wkhtmltopdf-binary. It works, and it is showing its age in a specific way. The
wkhtmltopdf engine is a fork of a WebKit that predates flexbox and grid, so a
layout built with either silently collapses. The project is unmaintained. And the
gem ships platform-specific binaries, which is a recurring surprise the first time
you deploy to arm64.
Grover is the modern replacement and it drives Puppeteer — so your Rails app now
also needs Node, Chromium and a process supervisor.
This is Net::HTTP from the standard library.
Render an ERB view
render_to_string gives you the HTML; the API returns the PDF.
require "net/http"
require "json"
require "uri"
class InvoicesController < ApplicationController
def show
respond_to do |format|
format.pdf do
html = render_to_string(
template: "invoices/show",
layout: "pdf",
formats: [:html],
assigns: { invoice: @invoice }
)
send_data render_pdf(html),
filename: "invoice-#{params[:id]}.pdf",
type: "application/pdf",
disposition: "attachment"
end
end
end
private
def render_pdf(html)
uri = URI("https://renderpaper.com/v1/render")
request = Net::HTTP::Post.new(uri)
request["X-API-Key"] = Rails.application.credentials.dig(:renderpaper, :api_key)
request["Content-Type"] = "application/json"
request.body = JSON.generate(template: html)
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)
raise "render failed: #{response.code}: #{response.body}"
end
response.body
end
end
Register the MIME type once, in config/initializers/mime_types.rb:
Mime::Type.register "application/pdf", :pdf
Because Chromium does the rendering, the CSS in your pdf layout is the same CSS
you would write for the web — including grid and flexbox.
Stored templates: send data, not markup
Rendering an ERB view keeps the document in your repository, so a design change is a deploy. Store the template and send data instead.
require "net/http"
require "json"
require "uri"
TEMPLATE_ID = "your-template-id"
uri = URI("https://renderpaper.com/v1/templates/#{TEMPLATE_ID}/render")
request = Net::HTTP::Post.new(uri)
request["X-API-Key"] = ENV.fetch("RENDER_API_KEY")
request["Content-Type"] = "application/json"
request.body = JSON.generate(
data: {
InvoiceNumber: "2026-0142",
Customer: { Name: "Northwind Trading AB" },
LineItems: [
{ Description: "Consulting", Quantity: 12, Amount: 14_400 },
{ Description: "Hosting", Quantity: 1, Amount: 4_350 }
],
Total: 18_750
}
)
response = Net::HTTP.start(uri.hostname, uri.port,
use_ssl: uri.scheme == "https",
read_timeout: 60) do |http|
http.request(request)
end
raise "render failed: #{response.code}" unless response.is_a?(Net::HTTPSuccess)
File.binwrite("invoice.pdf", response.body)
puts "wrote invoice.pdf"
The stored template is a Go html/template, which reads close enough to ERB to be
unsurprising:
<h1>Invoice {{.InvoiceNumber}}</h1>
<p>{{.Customer.Name}}</p>
<table>
{{range .LineItems}}
<tr><td>{{.Description}}</td><td>{{.Quantity}}</td><td>{{.Amount}}</td></tr>
{{end}}
</table>
Note the JSON keys are capitalised, because that is how the template addresses them. Ruby's symbol keys serialise to exactly those strings.
Do it in a background job
A render is a second or two — too long to hold a Puma thread for a batch:
class RenderInvoiceJob < ApplicationJob
queue_as :default
def perform(invoice_id)
invoice = Invoice.find(invoice_id)
html = ApplicationController.render(
template: "invoices/show",
layout: "pdf",
assigns: { invoice: invoice }
)
pdf = RenderpaperClient.new.render(html)
invoice.pdf.attach(
io: StringIO.new(pdf),
filename: "invoice-#{invoice_id}.pdf",
content_type: "application/pdf"
)
end
end
ApplicationController.render works outside a request, which is what makes the
job independent of the controller.
Why not wicked_pdf
wicked_pdf is a good gem wrapped around an engine that stopped. wkhtmltopdf is
archived, its WebKit fork predates flexbox and grid, and modern CSS degrades
quietly rather than erroring — the document is wrong in a way tests do not catch
and a human notices late.
It is still fine for a fixed, simple document that nobody redesigns.
Why not Grover
Grover is the honest successor: real Chrome, real CSS. The cost is that a Ruby application acquires a Node runtime, a Puppeteer install and a Chromium binary in every environment — plus process supervision, because browsers crash.
Worth it when documents must never leave your network. Otherwise it is a browser to run so you can print an invoice.
Try it without a key
https://renderpaper.com/sample.pdf is a real render, no account. The free tier is
50 documents a month with no card.