\begin{article}
LaTeX PDF Generation in Ruby on Rails
Add LaTeX PDF generation to your Rails app via API — no system dependencies, works on Heroku and any PaaS where you cannot install TeX Live.

If you have ever tried to generate a PDF from LaTeX inside a Rails app on Heroku or Render, you already know the problem: there is no buildpack that ships a full TeX Live distribution without bloating your slug to 4 GB or requiring a custom Docker image. The standard workarounds — pdflatex via a system call, latex_to_pdf gems that wrap local binaries, or running a sidecar container — all push infrastructure complexity into your application layer. FormatEx solves this by exposing a plain REST API: you POST LaTeX source, you get a PDF back. No binaries, no buildpacks, no maintenance.
This guide walks through calling the FormatEx LaTeX PDF API from Ruby on Rails using both the standard library (Net::HTTP) and Faraday, streaming the result to the browser, and handling errors in a production-ready way.
Prerequisites
Before writing any code you need:
- A Rails application (Rails 7+ recommended, but Rails 6 works fine)
- A FormatEx API key — create a free account, then go to Dashboard → API Keys → New Key and copy the key immediately (it is shown only once)
- Ruby 3.0 or later
The free plan gives you 15 compilations per month using the pdflatex engine, which is enough to validate the integration. Paid plans unlock more engines and higher limits. See the getting started guide for a full walkthrough of your first compilation.
The FormatEx Compile Endpoint
Every request goes to a single endpoint:
POST https://api.formatex.io/api/v1/compile
Content-Type: application/json
X-API-Key: YOUR_API_KEYThe request body is JSON with at minimum a latex field:
{
"latex": "\\documentclass{article}\\begin{document}Hello\\end{document}",
"engine": "pdflatex"
}On success the API returns a binary PDF with Content-Type: application/pdf. On failure it returns JSON with an error key and a non-200 status code. That contract is simple enough to wrap in a plain Ruby service object.
Supported engines are:
| Engine | Plans | Best for |
|---|---|---|
pdflatex | All plans | Most documents, fastest |
xelatex | Developer+ | Unicode fonts, system font access |
lualatex | Developer+ | Lua scripting, OpenType |
latexmk | Developer+ | Auto-resolving multi-pass documents |
For a deeper comparison of when to use each engine, see the complete guide to LaTeX engines.
Building a Rails Service Object
Keep external API calls out of controllers. A plain service object in app/services/ is the right place.
Create app/services/formatex_client.rb:
require "net/http"
require "uri"
require "json"
class FormatexClient
API_URL = URI.parse("https://api.formatex.io/api/v1/compile").freeze
class CompilationError < StandardError
attr_reader :status
def initialize(message, status:)
super(message)
@status = status
end
end
def initialize(api_key: ENV.fetch("FORMATEX_API_KEY"))
@api_key = api_key
end
# Returns a binary string (the PDF bytes) or raises CompilationError.
def compile(latex:, engine: "pdflatex")
request = Net::HTTP::Post.new(API_URL)
request["Content-Type"] = "application/json"
request["X-API-Key"] = @api_key
request.body = JSON.generate(latex: latex, engine: engine)
response = Net::HTTP.start(
API_URL.host,
API_URL.port,
use_ssl: true,
read_timeout: 120,
open_timeout: 10
) { |http| http.request(request) }
case response.code.to_i
when 200
response.body
when 401
raise CompilationError.new("Invalid or missing API key.", status: 401)
when 402
raise CompilationError.new("Compilation limit reached for current plan.", status: 402)
when 422
message = JSON.parse(response.body, symbolize_names: true).fetch(:error, "LaTeX compilation failed.")
raise CompilationError.new(message, status: 422)
else
raise CompilationError.new("Unexpected error (HTTP #{response.code}).", status: response.code.to_i)
end
end
endStore the API key in an environment variable. In development, set it in .env (with the dotenv-rails gem) or your credentials file. On Heroku or Render, add FORMATEX_API_KEY to your app's config vars — no buildpack needed. Follow API key management best practices to keep credentials secure across environments.
Controller Action: Compile and Stream
A typical use case is a controller that accepts a form submission, compiles a PDF, and streams it to the browser. Here is a complete example:
class ReportsController < ApplicationController
def create
latex_source = build_latex(params)
client = FormatexClient.new
pdf_bytes = client.compile(latex: latex_source, engine: "pdflatex")
send_data(
pdf_bytes,
filename: "report-#{Date.today}.pdf",
type: "application/pdf",
disposition: "attachment"
)
rescue FormatexClient::CompilationError => e
Rails.logger.error("[FormatEx] Compilation failed: #{e.message} (status #{e.status})")
flash[:error] = friendly_error(e.status)
redirect_to new_report_path
end
private
def build_latex(params)
# Replace with your actual template logic — ERB, Liquid, plain string, etc.
<<~LATEX
\\documentclass[12pt]{article}
\\usepackage[T1]{fontenc}
\\usepackage[utf8]{inputenc}
\\begin{document}
\\title{#{params[:title]}}
\\author{#{params[:author]}}
\\maketitle
#{params[:body]}
\\end{document}
LATEX
end
def friendly_error(status)
case status
when 401 then "API configuration error. Please contact support."
when 402 then "PDF generation limit reached. Please upgrade your plan."
when 422 then "Your document contains a LaTeX error. Please review the source."
else "PDF generation failed. Please try again."
end
end
endUse send_data with disposition: "inline" if you want the PDF to open in the browser tab instead of downloading.
Using Faraday Instead of Net::HTTP
If your project already uses Faraday, the service object becomes slightly more readable. Add gem "faraday" to your Gemfile if it is not already there, then create app/services/formatex_client_faraday.rb:
require "faraday"
require "faraday/net_http"
require "json"
class FormatexClientFaraday
API_BASE = "https://api.formatex.io".freeze
class CompilationError < StandardError
attr_reader :status
def initialize(message, status:) = (super(message); @status = status)
end
def initialize(api_key: ENV.fetch("FORMATEX_API_KEY"))
@connection = Faraday.new(url: API_BASE) do |f|
f.request :json
f.adapter :net_http
f.options.timeout = 120
f.options.open_timeout = 10
end
@api_key = api_key
end
def compile(latex:, engine: "pdflatex")
response = @connection.post("/api/v1/compile") do |req|
req.headers["X-API-Key"] = @api_key
req.body = { latex: latex, engine: engine }
end
return response.body if response.status == 200
message = begin
JSON.parse(response.body)["error"] || "Compilation failed."
rescue JSON::ParserError
"Compilation failed."
end
raise CompilationError.new(message, status: response.status)
end
endBoth clients are interchangeable — pick whichever fits your project. Net::HTTP has zero external dependencies, which makes it the better default on a fresh service.
Inline PDF Rendering with JavaScript
If you want to display the PDF inline instead of triggering a download, render the bytes as a data URL via a Stimulus controller:
// app/javascript/controllers/pdf_preview_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["frame"]
async load(event) {
event.preventDefault()
const form = event.target
const response = await fetch(form.action, {
method: "POST",
body: new FormData(form),
headers: { Accept: "application/pdf" }
})
if (!response.ok) {
// Let the server redirect handle the error flash
window.location = form.action
return
}
const blob = await response.blob()
const url = URL.createObjectURL(blob)
this.frameTarget.src = url
}
}In the view:
<div data-controller="pdf-preview">
<%= form_with url: reports_path, data: { action: "submit->pdf-preview#load" } do |f| %>
<%= f.text_field :title %>
<%= f.text_area :body %>
<%= f.submit "Generate PDF" %>
<% end %>
<iframe data-pdf-preview-target="frame" style="width:100%;height:800px;"></iframe>
</div>Error Handling and Logging
A few patterns that matter in production:
- Never log the LaTeX source. It may contain sensitive data (names, addresses, financial figures). Log only the status code and a sanitized message.
- Set a generous read timeout. Complex documents with many references or large tables can take 20—60 seconds. A timeout of 120 seconds covers all realistic cases on the Developer and Pro plans.
- Surface LaTeX errors to developers, not end users. The 422 response body from FormatEx includes the
pdflatexstderr output. Log it server-side and show a generic message to users. The LaTeX API error codes guide covers all status codes including 422 compile failures and 429 rate limit responses. - Use background jobs for non-blocking generation. If the user does not need the PDF immediately, enqueue a Sidekiq or GoodJob job, upload the result to S3, and notify the user when it is ready. For more advanced patterns including webhooks, see async LaTeX compilation and webhooks.
A minimal Sidekiq job looks like this:
class GeneratePdfJob < ApplicationJob
queue_as :default
def perform(record_id, latex_source)
pdf_bytes = FormatexClient.new.compile(latex: latex_source)
record = Report.find(record_id)
record.pdf_attachment.attach(
io: StringIO.new(pdf_bytes),
filename: "report.pdf",
content_type: "application/pdf"
)
rescue FormatexClient::CompilationError => e
Rails.logger.error("[FormatEx] Job failed for record #{record_id}: #{e.message}")
# Optionally notify Honeybadger / Sentry / etc.
end
endDeploying to Heroku or Render
This is where the FormatEx approach genuinely pays off. Your Procfile stays exactly as it is. You do not need:
- A custom
Aptfilewithtexlive-full - A Docker-based buildpack
- A separate worker dyno running pdflatex
- Any changes to your
heroku.ymlorrender.yaml
The only deployment step is adding the environment variable:
# Heroku
heroku config:set FORMATEX_API_KEY=your_key_here
# Render — add via the dashboard Environment tab, or render.yaml:
# envVars:
# - key: FORMATEX_API_KEY
# sync: falseYour slug size stays small, cold-boot time stays fast, and the PDF generation scales automatically with the FormatEx infrastructure rather than your dynos. If you are weighing this against self-hosting LaTeX on your own server, the infrastructure and maintenance cost difference is significant at any scale.
Get Started
The free plan is enough to integrate and test end-to-end. Once you are generating PDFs in production, upgrading to Developer ($12/month) unlocks additional engines and 200 compilations per month — enough for most small to medium Rails applications.
Create your free FormatEx account and have your first Rails PDF generating in under 15 minutes.
Related Articles
- Getting Started with FormaTeX — Step-by-step setup for your first API key and first compilation, directly applicable before wiring up Rails
- The Complete Guide to LaTeX Engines — When to use pdfLaTeX, XeLaTeX, LuaLaTeX, or latexmk — relevant for choosing the right engine parameter in your Rails service
- LaTeX API Error Codes: Complete Guide — Full reference for 422, 429, and all other status codes your Rails error handler needs to cover
- Async LaTeX Compilation and Webhooks — Production patterns for background job PDF generation, polling, and webhook callbacks from Rails
- Self-Hosting LaTeX vs. Using a Compilation API — Cost and complexity breakdown that explains why the API approach is preferable on Heroku and Render
\end{article}
\related{posts}




