Renderpaper
Log in Start free

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.

Last updated 2026-08-08.