Get started
PDF generation in Rails: every option compared in 2026

PDF generation in Rails: every option compared in 2026

PDF generation in Ruby on Rails in 2026: WickedPdf, Grover, Ferrum PDF, ChromicPDF wrappers, Prawn, hosted APIs. Pick the right one for invoices and reports.

19 min read

Rails has five real PDF paths in 2026: WickedPdf for legacy wkhtmltopdf apps, Grover for full-Chromium rendering via Puppeteer, Ferrum PDF for Chromium-quality rendering without Node, Prawn for pure-Ruby programmatic output, and a hosted HTML-to-PDF API for zero-ops scale. Rule of thumb: Grover when Node is fine on the host, Ferrum PDF when you want to drop Node, Prawn when there is no HTML, hosted API when Chromium does not belong in your container. A working Rails controller that returns a PDF is six lines either way.

PDF generation in Rails at a glance

Five options cover almost every production Rails codebase. The trade-offs split across native dependencies, CSS fidelity, and operational footprint.

GemNative depsCSS supportJS supportLicenseBest for
WickedPdfwkhtmltopdf binary (archived 2023)Qt 4 WebKit (frozen 2014)Partial, unreliableMIT (wrapper)Legacy apps only
GroverNode 18+, Puppeteer, ChromiumFull ChromiumYesMITERB-driven PDFs, full CSS
Ferrum PDFChromium binaryFull ChromiumYesMITSame as Grover, no Node
PrawnNone (pure Ruby)None (DSL only)NoneHippocratic / GPL hybridProgrammatic PDFs, no HTML
Hosted API (PDF4.dev, etc.)NoneFull ChromiumYesRESTHigh volume, zero-ops teams

Most Rails teams land on Grover or Ferrum PDF for new builds, keep a Prawn service object for ad-hoc reports, and call a hosted API once volume crosses a few thousand renders per day or Heroku/Lambda becomes the deploy target. WickedPdf only stays in the picture for codebases inherited from the 2015-2018 era.

Option 1: WickedPdf (legacy, wkhtmltopdf wrapper)

WickedPdf wraps the wkhtmltopdf binary, which renders HTML using a fork of Qt 4 WebKit. From around 2012 to 2020, this was the default PDF stack on Rails: one gem, one binary, ERB templates rendered to PDF in a controller.

The wkhtmltopdf repository was archived in 2023 and the project explicitly states it is no longer maintained. The bundled Qt WebKit is a fork of a 2014-era browser engine. Several CVEs sit unpatched, JavaScript support is partial and inconsistent, and modern CSS (Flexbox, Grid, CSS variables, custom properties) is missing or broken. The Rails community has been moving off wkhtmltopdf since the official deprecation thread on the Rails discussion forum. Treat any WickedPdf install as legacy code and plan a migration. Do not start new Rails projects on this stack.

If you inherited a WickedPdf codebase, the wiring looks like this for context:

# Gemfile
gem 'wicked_pdf'
gem 'wkhtmltopdf-binary'
# config/initializers/wicked_pdf.rb
WickedPdf.config = {
  exe_path: '/usr/local/bin/wkhtmltopdf'
}
# app/controllers/invoices_controller.rb
class InvoicesController < ApplicationController
  def show
    @invoice = Invoice.find(params[:id])
 
    respond_to do |format|
      format.pdf do
        render pdf: "invoice-#{@invoice.number}",
               template: 'invoices/show',
               formats: [:html],
               disposition: 'inline'
      end
    end
  end
end

The migration story. WickedPdf and Grover have nearly identical APIs once you stop using the render pdf: Rails responder and call the gem directly. The hard work is the template: Chromium accepts CSS that wkhtmltopdf silently dropped, and wkhtmltopdf accepts layout shortcuts that Chromium rejects. Budget half a day per non-trivial template for the regression pass.

Option 2: Grover (Puppeteer wrapper)

Grover is a Ruby wrapper around Puppeteer, the Node library that drives headless Chromium. It is the closest semantic replacement for WickedPdf: pass an HTML string, get PDF bytes. The CSS fidelity is identical to what a user sees in Chrome.

Install:

# Gemfile
gem 'grover'
bundle install
npm install puppeteer

Puppeteer downloads a pinned Chromium build (~280 MB) into node_modules. On servers where you do not ship node_modules (typical Rails deploys), install a system Chromium and point Grover at it via executable_path.

The ERB template (app/views/invoices/show.html.erb):

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <link rel="stylesheet" href="<%= asset_path('application.css') %>">
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
  <style>
    @page { size: A4; margin: 20mm 15mm; }
    body { font-family: 'Inter', sans-serif; color: #111; }
    .header { display: flex; justify-content: space-between; align-items: center; }
    table { width: 100%; border-collapse: collapse; margin-top: 8mm; }
    th, td { padding: 6px 10px; border-bottom: 1px solid #eee; text-align: left; }
    .total { font-weight: 600; font-size: 14px; }
  </style>
</head>
<body>
  <div class="header">
    <h1>Invoice <%= @invoice.number %></h1>
    <img src="<%= asset_path('logo.png') %>" width="120">
  </div>
  <p>Bill to: <%= @invoice.client_name %></p>
  <table>
    <thead>
      <tr><th>Description</th><th>Qty</th><th>Unit</th><th>Subtotal</th></tr>
    </thead>
    <tbody>
      <% @invoice.lines.each do |line| %>
        <tr>
          <td><%= line.description %></td>
          <td><%= line.qty %></td>
          <td><%= number_to_currency(line.unit_price) %></td>
          <td><%= number_to_currency(line.subtotal) %></td>
        </tr>
      <% end %>
    </tbody>
    <tfoot>
      <tr class="total"><td colspan="3">Total</td><td><%= number_to_currency(@invoice.total) %></td></tr>
    </tfoot>
  </table>
</body>
</html>

The controller:

class InvoicesController < ApplicationController
  def show
    @invoice = Invoice.find(params[:id])
    html = render_to_string(template: 'invoices/show', layout: false)
 
    pdf = Grover.new(html, format: 'A4', margin: {
      top: '20mm', bottom: '20mm', left: '15mm', right: '15mm'
    }).to_pdf
 
    send_data pdf,
              filename: "invoice-#{@invoice.number}.pdf",
              type: 'application/pdf',
              disposition: 'inline'
  end
end

render_to_string compiles the ERB view to HTML in memory, Grover hands the string to Puppeteer, and Puppeteer drives Chromium to produce the PDF. Swap disposition: 'inline' for disposition: 'attachment' to force a download.

The production deployment story. Each render boots Chromium unless you pool the browser. The naive pattern (one render = one Chromium launch) costs 300-700ms per cold start and pegs CPU on a small VPS. Two production patterns work:

  • Background-job the render. Dispatch a GenerateInvoicePdfJob to Sidekiq or GoodJob. A long-running worker keeps Chromium warm across renders via Grover's connection reuse. The controller responds in 20ms while the job finishes in 200ms.
  • Run a Browserless sidecar. Stand up a browserless or self-hosted Chromium service and point Grover at it via browser_ws_endpoint. The Rails container stays slim; the Chromium ops move to a separate service.

Container overhead. Chromium adds ~300 MB to a Docker image and 50-100 MB of RAM per concurrent render. On Heroku, the Puppeteer Heroku buildpack gets you there but eats most of the 500 MB slug limit. On Fly.io or Render, a beefier instance plus a Dockerfile that installs chromium is usually fine.

Option 3: Ferrum PDF (pure Ruby + CDP)

Ferrum speaks the Chrome DevTools Protocol directly from Ruby, with no Node sidecar and no Puppeteer. You still need a Chromium binary on the host, but the Node toolchain disappears. The same project ships an in-tree PDF helper, and several Rails apps have publicly documented migrations from Grover to Ferrum for exactly this reason.

Install:

# Gemfile
gem 'ferrum'
bundle install
# Plus a Chromium binary, for example:
apt-get install -y chromium

No npm install, no node_modules, no Puppeteer download. The Docker image drops by ~250 MB compared to a Grover setup.

The controller:

require 'ferrum'
 
class InvoicesController < ApplicationController
  def show
    @invoice = Invoice.find(params[:id])
    html = render_to_string(template: 'invoices/show', layout: false)
 
    browser = Ferrum::Browser.new(browser_path: '/usr/bin/chromium')
    page = browser.create_page
    page.content = html
    pdf_bytes = page.pdf(
      format: :A4,
      margin: { top: 0.8, bottom: 0.8, left: 0.6, right: 0.6 },
      print_background: true,
      encoding: :binary
    )
    browser.quit
 
    send_data pdf_bytes,
              filename: "invoice-#{@invoice.number}.pdf",
              type: 'application/pdf',
              disposition: 'inline'
  end
end

In production, replace the per-request Ferrum::Browser.new with a shared browser singleton or a connection pool. A typical pattern is one Ferrum::Browser instance per Sidekiq worker, reused across jobs, with a daily restart to release memory.

The trade-offs against Grover. Same Chromium engine, same CSS fidelity, same render times. You drop Node and one wrapper layer; the gem ecosystem around Ferrum is smaller than Grover's, and a few advanced Puppeteer features (request interception, network mocks) need lower-level CDP calls instead of one-line Grover options. For straight HTML-to-PDF, Ferrum PDF is the lighter dependency. For complex browser automation in the same codebase, Grover's Puppeteer surface is broader.

A note on ChromicPDF. ChromicPDF is an Elixir library, not a Ruby gem. Ruby wrappers exist as community ports but are rare in production Rails apps. If you are choosing between modern Chromium-based gems in Ruby, the practical pick is Grover or Ferrum.

Option 4: Prawn (pure Ruby, programmatic)

Prawn is a pure-Ruby PDF generation library. No HTML, no CSS, no browser. You build the PDF as Ruby code: text positioning, lines, tables, images, fonts. The output is byte-deterministic and the dependency surface is zero native libraries.

Install:

# Gemfile
gem 'prawn'
gem 'prawn-table'

A minimal invoice service object:

require 'prawn'
require 'prawn/table'
 
class InvoicePdf
  def initialize(invoice)
    @invoice = invoice
  end
 
  def render
    pdf = Prawn::Document.new(page_size: 'A4', margin: [40, 40, 40, 40])
 
    pdf.text "Invoice #{@invoice.number}", size: 22, style: :bold
    pdf.move_down 8
    pdf.text "Bill to: #{@invoice.client_name}"
    pdf.text "Due: #{@invoice.due_date.strftime('%B %-d, %Y')}"
    pdf.move_down 16
 
    rows = [['Description', 'Qty', 'Unit', 'Subtotal']]
    @invoice.lines.each do |line|
      rows << [line.description, line.qty, format_money(line.unit_price), format_money(line.subtotal)]
    end
    rows << ['', '', 'Total', format_money(@invoice.total)]
 
    pdf.table(rows, header: true, width: pdf.bounds.width, cell_style: { padding: 6, borders: [:bottom] })
 
    pdf.number_pages "Page <page> of <total>", at: [pdf.bounds.right - 100, 0], align: :right
 
    pdf.render
  end
 
  private
 
  def format_money(value)
    "$#{format('%.2f', value)}"
  end
end

The controller:

class InvoicesController < ApplicationController
  def show
    @invoice = Invoice.find(params[:id])
    send_data InvoicePdf.new(@invoice).render,
              filename: "invoice-#{@invoice.number}.pdf",
              type: 'application/pdf',
              disposition: 'inline'
  end
end

The honest trade-offs. Prawn is the fastest path: no browser launch, no HTML parse, just a Ruby method writing bytes. A typical invoice renders in 50-100ms with negligible RAM. The cost is the design loop. Every visual change is Ruby code: changing a font means a new method call, changing a layout means recomputing positions, and a designer cannot edit it without learning the DSL. Prawn shines for back-office reports, certificates, and any PDF you treat as a code artifact. It is the wrong tool the moment marketing wants to redesign the invoice.

License note. Prawn's licensing has shifted over the years (GPL, Ruby, and more recently a hybrid). Check the current LICENSE file before shipping to a customer-facing product, and confirm with your legal team if you distribute the binary.

Option 5: call a hosted HTML-to-PDF API from Rails

The fifth path keeps Rails free of Node, Chromium, and binary installs. A hosted API runs the browser fleet on its side and returns a PDF over HTTP. The Rails code is a single Net::HTTP or Faraday call.

PDF4.dev call from a Rails controller:

require 'net/http'
require 'json'
 
class InvoicesController < ApplicationController
  def show
    @invoice = Invoice.find(params[:id])
 
    uri = URI('https://pdf4.dev/api/v1/render')
    req = Net::HTTP::Post.new(uri, {
      'Authorization' => "Bearer #{Rails.application.credentials.pdf4_key}",
      'Content-Type'  => 'application/json'
    })
    req.body = {
      template_id: 'invoice',
      data: {
        invoice_number: @invoice.number,
        client_name: @invoice.client_name,
        total: format('%.2f', @invoice.total),
        lines: @invoice.lines.as_json
      }
    }.to_json
 
    res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
 
    send_data res.body,
              filename: "invoice-#{@invoice.number}.pdf",
              type: 'application/pdf',
              disposition: 'inline'
  end
end

That is the whole integration. Templates live in the PDF4.dev dashboard (raw HTML with Handlebars variables) and are edited without redeploying the Rails app. Designers can iterate on the layout, marketing can change copy, and the Rails codebase never moves.

The equivalent in other languages:

Net::HTTP.post(
  URI('https://pdf4.dev/api/v1/render'),
  { template_id: 'invoice', data: data }.to_json,
  'Authorization' => "Bearer #{key}",
  'Content-Type'  => 'application/json'
)

The honest framing: you pay per render in exchange for not running a browser. The Rails container stays slim, Heroku deploys take seconds instead of minutes, and designers edit templates in a UI. Below a few hundred PDFs a day the cost is negligible; above tens of thousands a day, do the math against a self-hosted Grover or Ferrum pool plus the engineer time to keep it running.

Real-world decision tree

Six questions, stop at the first yes.

  1. Are you on Heroku, Fly.io tiny instances, or any platform where shipping Chromium is painful? Use a hosted API. Slug size, buildpack count, and cold-start cost all collapse to one HTTP call.
  2. Is the PDF a structured back-office report (certificate, ticket, packing slip) and you control both the design and the code? Use Prawn. Pure Ruby, no browser, fastest path, no native deps.
  3. Does the template use Tailwind, Bootstrap, custom webfonts, or any modern CSS? You need Chromium. Grover if your stack already has Node, Ferrum PDF if you want to drop the Node dependency.
  4. Are you migrating off WickedPdf? Move to Grover for the closest API (same render_to_string pattern, same HTML input), or to a hosted API if you also want to drop the wkhtmltopdf binary and any Chromium ops.
  5. Are you generating thousands of PDFs per day with bursts? Hosted API. Zero ops, no Chromium fleet to scale, scales horizontally for free.
  6. Do you have a DevOps team and tens of thousands of internal PDFs per day? A Grover or Ferrum worker pool on dedicated infrastructure can beat per-render API pricing once engineer time is amortized over months.

Migrating from WickedPdf to Grover: production playbook

The migration is mostly mechanical, with one bag of subtle CSS work. Plan for a full day per non-trivial template plus a few hours to update CI.

Step 1: update the Gemfile.

# Remove
# gem 'wicked_pdf'
# gem 'wkhtmltopdf-binary'
 
# Add
gem 'grover'
bundle install
npm install puppeteer

Step 2: install Chromium in the Dockerfile. If Puppeteer's bundled Chromium is not acceptable in the production image, install the system package:

# Dockerfile
RUN apt-get update && apt-get install -y \
    chromium fonts-liberation libnss3 \
    && rm -rf /var/lib/apt/lists/*
 
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium

Step 3: rewrite the controller. The render pdf: shortcut from WickedPdf goes away; replace it with explicit render_to_string plus a Grover call.

# Before (WickedPdf)
def show
  @invoice = Invoice.find(params[:id])
  respond_to do |format|
    format.pdf do
      render pdf: "invoice-#{@invoice.number}",
             template: 'invoices/show',
             disposition: 'inline'
    end
  end
end
 
# After (Grover)
def show
  @invoice = Invoice.find(params[:id])
  html = render_to_string(template: 'invoices/show', layout: false)
  pdf = Grover.new(html, format: 'A4', margin: {
    top: '20mm', bottom: '20mm', left: '15mm', right: '15mm'
  }).to_pdf
 
  send_data pdf,
            filename: "invoice-#{@invoice.number}.pdf",
            type: 'application/pdf',
            disposition: 'inline'
end

Step 4: fix the template. This is where the time goes. Five patterns reliably break in the move:

  • Page breaks. wkhtmltopdf was lenient with page-break-after; Chromium prefers the modern break-after: page and applies it more strictly. Audit every long table and every section divider.
  • Webfonts. WickedPdf reads @font-face URLs through wkhtmltopdf's HTTP client; Chromium uses its own. Absolute URLs (including the asset host) work; relative paths often fail. Switch to asset_url instead of asset_path for font and image references.
  • Headers and footers. WickedPdf's :header and :footer options become Grover's display_header_footer: true plus header_template/footer_template HTML strings.
  • Image paths. WickedPdf accepted <img src="/assets/logo.png"> and resolved it via the Rails server; Chromium needs the full URL or an embedded data: URI.
  • JavaScript. wkhtmltopdf's JS support was partial; Chromium runs everything. If your template relied on JS to bail out or to defer rendering, audit the wait_until option (networkidle0 is the safest default).

Step 5: regression-test. Render the same invoice with the old and new stacks, diff the PDFs visually (pdftotext, pdfimages, or a service like Diffy), and fix every mismatch. Budget the time for this honestly.

Step 6: deploy. Push to a staging environment first. Grover's Chromium adds 250-300 MB to the slug or image, which can blow Heroku's slug limit if you have not pruned other dependencies. Verify memory headroom: Chromium needs 50-100 MB of RSS per concurrent render.

Benchmark on a real invoice

Conditions: 8-page A4 invoice, two-column header, table of 50 line items, Inter webfont, Rails 7.1 on a 2 vCPU / 4 GB VPS. Numbers are median per render across 10K invocations. Gem versions: WickedPdf 2.7 / wkhtmltopdf 0.12.6 (archived), Grover 1.1, Ferrum 0.15, Prawn 2.5, PDF4.dev API.

OptionMedian timeRAM per renderInfrastructure costLicense / API cost
WickedPdf~150ms~40 MBwkhtmltopdf binary$0 (deprecated)
Grover (warm browser)~500ms~80 MB+Node + Chromium, ~$30-50/mo server$0
Grover (cold launch)~1100ms~80 MBSame$0
Ferrum PDF (warm browser)~400ms~70 MB+Chromium only, ~$30/mo server$0
Prawn~80ms~25 MBBundled into Rails app$0
PDF4.dev (hosted)~250ms (incl. network)0 MB locallyNonePer-render API pricing

Three observations. Prawn is the fastest in-process option because it skips a layout engine entirely; if speed dominates and the PDF is structured, Prawn wins on raw throughput. Ferrum saves about 100ms per warm render against Grover thanks to one fewer wrapper layer (CDP directly versus Puppeteer indirection). RAM at concurrency is where Chromium-based options get expensive: 100 simultaneous renders means 8-10 GB of memory on Grover or Ferrum versus zero on the API path.

PDF4.dev exposes a REST endpoint that any Rails app can call with Net::HTTP, Faraday, or HTTParty. Templates use raw HTML with Handlebars variables and are edited in a dashboard. The free tier covers the first hundreds of renders per month, and the per-render price scales linearly above that. No Chromium in your container, no Node toolchain on the server.

Production tips per path

WickedPdf. Treat it as a temporary state and plan the migration. If you must keep it running short-term, pin wkhtmltopdf-binary to a known-good version and accept that no security patches are coming. Never expose the renderer to user-supplied HTML.

Grover. Always background-job the render for any request the user does not need synchronously. Reuse one Chromium across jobs in the worker; cold launch is the biggest variance in the benchmark. Set a hard timeout (30s) so a stuck Chromium does not pin a worker. Monitor RSS memory on the worker container; Chromium leaks slowly and a daily restart is fine.

Ferrum PDF. Wrap Ferrum::Browser.new in a singleton or pool: per-request browsers are the difference between 400ms and 1100ms. Set Ferrum::Browser.new(timeout: 30, process_timeout: 30) to bound stuck processes. Use print_background: true to make CSS backgrounds render; the default is off, which surprises everyone the first time.

Prawn. Build a service object (InvoicePdf, CertificatePdf) rather than inlining the DSL in the controller. Cache by hashed input when the inputs are stable. Watch font registration: custom TTFs need explicit pdf.font_families.update(...). Use prawn-table for any tabular layout; the core library's table support is rudimentary.

Hosted API. Use delivery: "url" for PDFs larger than 1 MB so you do not pay for a base64 round-trip on every response. Send an idempotency key on retries to avoid double-billing on network blips. Catch 429 (rate limit) and retry with exponential backoff. Log duration_ms from every response and alert on regressions.

Common to all five. Log every render's duration to APM (New Relic, Datadog, Skylight). Alert when the p95 doubles. Test with realistic data sizes, not the 3-line lorem ipsum example. Generate the production PDF in CI on every deploy and diff against the previous build.

Frequently asked questions

What is the best PDF gem for Rails in 2026? Depends on the PDF. For ERB-driven invoices with full CSS, Grover or Ferrum PDF. For programmatic back-office reports with no HTML, Prawn. For zero-ops scale, a hosted API. Avoid WickedPdf in new code.

Is WickedPdf still safe to use? Functionally yes, ethically no. wkhtmltopdf is archived since 2023, several CVEs are unpatched, and CSS support is frozen at 2014 levels. Treat existing installs as legacy and plan a migration.

How do I migrate from WickedPdf to Grover? Replace wicked_pdf with grover in the Gemfile, install Puppeteer, swap the render pdf: controller block for render_to_string plus Grover.new(html).to_pdf, regression-test the template, update the Dockerfile to install Chromium, deploy.

Does Grover require Node.js? Yes. Grover wraps Puppeteer, which is a Node library. Ferrum PDF is the equivalent without the Node requirement: pure Ruby talking CDP directly to a Chromium binary.

Can I use Tailwind CSS with Rails PDF generation? With Grover, Ferrum PDF, or a hosted API: yes, full fidelity. With WickedPdf: no, the frozen WebKit lacks the layout primitives Tailwind depends on. With Prawn: not applicable, there is no HTML.

What is the best way to deploy a Rails PDF generator to Heroku? A hosted PDF API is the cleanest path on Heroku because shipping Chromium fights the slug size limit. Pure-Ruby Prawn fits Heroku without buildpacks. Grover or Ferrum work with the right buildpacks but add 200+ MB to the slug and slow down every deploy.

How do I add page numbers to a Rails PDF? With Grover or Ferrum PDF, use the CSS @page rule with counters, or pass display_header_footer: true plus a footer template containing the page-number placeholder elements. With Prawn, call number_pages with a format string. With a hosted API, configure the format object on the render call.

Is Prawn faster than Grover? Yes, by 5-10x for a typical invoice. Prawn writes PDF bytes directly from Ruby without booting a browser. The cost is that Prawn is a layout DSL, not an HTML renderer.

Can I generate a Rails PDF from an ERB template? Yes. Call render_to_string to compile the ERB view to HTML, then pass the HTML to Grover, Ferrum PDF, or a hosted API. The Action View renderer and the PDF engine are decoupled.

Does Rails 7 have built-in PDF generation? No. Rails ships HTML, JSON, XML, and ATOM renderers in Action Controller but not PDF. PDF generation is always a third-party gem or a hosted API call. Rails 8 follows the same pattern.

Summary

Rails has five real paths to a PDF. WickedPdf for legacy code on its way out. Grover for ERB-driven Chromium rendering when Node is fine on the host. Ferrum PDF for the same Chromium fidelity without Node, talking CDP directly from Ruby. Prawn for pure-Ruby programmatic PDFs where HTML is not the right input. A hosted HTML-to-PDF API when Chromium does not belong in your container or your deploy target makes it expensive. Start with Grover or Ferrum if the design is HTML-driven, fall back to Prawn for structured back-office work, and call a hosted API once the operational cost of running a browser stops being worth it.

Ready to drop Chromium from your Rails containers? Create a free PDF4.dev account, grab an API key, and replace your PDF gem with a single Net::HTTP call. Templates are edited in a dashboard, not in app/views.

Try it first with the free HTML to PDF converter, or read the companion articles for Node.js, Python, Laravel, Spring Boot, and the broader PDF generation best practices guide.

Free tools mentioned:

Html To PdfTry it free

Start generating PDFs

Build PDF templates with a visual editor. Render them via API from any language in ~300ms.