Generating PDFs in Rails with an API (No wkhtmltopdf)

wkhtmltopdf was the Rails standard for server-side PDF generation for about a decade. The project has no active maintainer since 2020, ships with a patched 2012 WebKit build, and segfaults under concurrent load in containerized environments. If you maintain a Rails app that generates PDFs from URLs, you have almost certainly hit at least one of those problems.
A hosted rendering API handles the browser process for you. You make one HTTP request and get back a PDF. No binary to install, no memory to manage, no process to restart when it crashes.
Prerequisites
- Rails 7+ application (Rails 6 works; the code is identical)
- Bundler
- A Rendex API key. The free tier covers 100 calls/month with no credit card required. Get one in the dashboard.
Quick test with curl
Before touching your Gemfile, confirm the API works for your target URL:
curl -X POST https://api.rendex.dev/v1/screenshot \
-H "Authorization: Bearer rdx_your_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com",
"format": "pdf",
"pdfFormat": "A4",
"pdfPrintBackground": true,
"pdfMargin": {
"top": "20mm",
"right": "15mm",
"bottom": "20mm",
"left": "15mm"
}
}' --output test.pdf
# test.pdf should be a valid PDF
# Get your API key at https://rendex.dev/loginThe response body is raw PDF bytes. You stream them directly to the client or write them to disk. No base64 decoding, no JSON unwrapping.
Step 1: Add Faraday
Faraday is the standard Ruby HTTP client for Rails projects. Add it to your Gemfile if you do not already have it:
gem "faraday"Run bundle install.
Step 2: Create a PdfService
Wrap the Rendex API in a thin service object. This keeps HTTP details out of your controllers and makes the service easy to stub in tests.
require "faraday"
require "json"
class PdfService
RENDEX_URL = "https://api.rendex.dev/v1/screenshot"
def initialize(api_key = ENV.fetch("RENDEX_API_KEY"))
@api_key = api_key
end
# Captures url as a PDF and returns raw bytes.
#
# Options:
# :page_size — "A4", "Letter", "Legal", "Tabloid", "A3" (default: "A4")
# :landscape — true/false (default: false)
# :print_background — include background colors and images (default: true)
# :margin — hash with string values, e.g. { top: "20mm", right: "15mm" }
#
def capture(url, options = {})
conn = Faraday.new do |f|
f.response :raise_error
end
response = conn.post(RENDEX_URL) do |req|
req.headers["Authorization"] = "Bearer #{@api_key}"
req.headers["Content-Type"] = "application/json"
req.body = JSON.dump(build_params(url, options))
end
response.body
end
private
def build_params(url, options)
{
url: url,
format: "pdf",
pdfFormat: options.fetch(:page_size, "A4"),
pdfLandscape: options.fetch(:landscape, false),
pdfPrintBackground: options.fetch(:print_background, true),
pdfMargin: options.fetch(:margin, default_margin),
}
end
def default_margin
{ top: "20mm", right: "15mm", bottom: "20mm", left: "15mm" }
end
endSet RENDEX_API_KEY in your environment. For local development, add it to your .env file or Rails credentials. API keys carry the prefix rdx_, a single segment with no environment suffix, and are visible in the Rendex dashboard after signup.
Step 3: Stream a PDF from a controller
Once the service exists, sending a PDF to the browser is two lines:
class ReportsController < ApplicationController
before_action :authenticate_user!
def show
# The rendering API fetches this URL from Cloudflare's edge network.
# localhost and private VPC addresses won't resolve from there.
# Use the public hostname or a tunnel for local development.
report_url = report_url(params[:id], host: request.host_with_port)
pdf_bytes = PdfService.new.capture(report_url, page_size: "Letter")
send_data(
pdf_bytes,
filename: "report-#{params[:id]}.pdf",
type: "application/pdf",
disposition: "inline"
)
end
endStep 4: Background jobs for bulk exports
For large batches or reports that take longer than a browser request should block, run the capture in a background job and attach the result to Active Storage.
class PdfExportJob < ApplicationJob
queue_as :default
def perform(export_id)
export = PdfExport.find(export_id)
pdf_bytes = PdfService.new.capture(
Rails.application.routes.url_helpers.export_url(
export,
host: ENV.fetch("APP_HOST")
),
page_size: "A4"
)
export.file.attach(
io: StringIO.new(pdf_bytes),
filename: "export-#{export.id}.pdf",
content_type: "application/pdf"
)
export.update!(status: "ready", completed_at: Time.current)
end
endEnqueue it from a controller action and return immediately:
def create
export = current_user.pdf_exports.create!(status: "queued")
PdfExportJob.perform_later(export.id)
render json: { id: export.id, status: "queued" }
endMigrating from wkhtmltopdf or WickedPdf
The wicked_pdf and pdfkit gems both wrap wkhtmltopdf. Swapping them out is a service-boundary replacement:
| Before (wkhtmltopdf) | After (Rendex API) |
|---|---|
| WickedPdf.new.pdf_from_string(html) | PdfService.new.capture(url) |
| PDFKit.new(html).to_pdf | PdfService.new.capture(url) |
| render pdf: "report" | send_data(pdf_bytes, ...) |
The key difference: instead of passing an HTML string directly, you pass a public URL. For workflows that build HTML in memory (templates without a routable URL), the API accepts raw HTML via the html parameter up to 5 MB. Full parameter reference at docs/api-reference.
PDF output options
The API supports the same options you would pass to Chrome's print dialog. The ones that matter most in practice:
- pdfFormat:
"A4","Letter","Legal","Tabloid","A3" - pdfLandscape:
truefor wide content like dashboards and data tables - pdfPrintBackground: set to
trueto include CSS background colors and images (the API defaults totrue) - pdfMargin: object with
top,right,bottom,leftas CSS strings. Use"0"for edge-to-edge output. - pdfScale: 0.1 to 2.0. Shrink content to fit more per page or enlarge for readability.
Troubleshooting
Faraday::UnauthorizedError (401) RENDEX_API_KEY is missing or invalid. Check it with puts ENV["RENDEX_API_KEY"] in a Rails console.
PDF renders with missing background colors or images Confirm pdfPrintBackground is true in your request. It is the default in PdfService above, but easy to override accidentally.
JavaScript-rendered content is missing. Add waitUntil: "networkidle0" to the request body. This instructs the renderer to wait until network activity settles before capturing the page.
Private URL not loading. Rendex fetches pages from Cloudflare's edge. Internal addresses like localhost and private VPC ranges are not reachable. For pages behind authentication, pass session cookies or auth headers using the cookies and headers parameters. See the API reference for examples.
Next steps
For the same pattern in Python, see How to Generate PDFs from URLs in Python. It covers four approaches from the Rendex SDK to Playwright and Selenium.
Evaluating which hosted PDF API fits your use case? The URL-to-PDF API comparison covers DocRaptor, PDFShift, and Rendex with honest pricing and CSS compatibility notes.
Try the free URL-to-PDF converterto capture a page without writing any code. When you're ready to integrate, get an API key. The free tier includes 100 calls/month with no credit card required.