FormaTeX

\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.

·8 min read·
LaTeX PDF Generation in Ruby on Rails

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:

text
POST https://api.formatex.io/api/v1/compile
Content-Type: application/json
X-API-Key: YOUR_API_KEY

The request body is JSON with at minimum a latex field:

json
{
  "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:

EnginePlansBest for
pdflatexAll plansMost documents, fastest
xelatexDeveloper+Unicode fonts, system font access
lualatexDeveloper+Lua scripting, OpenType
latexmkDeveloper+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:

ruby
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
end

Store 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:

ruby
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
end

Use 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:

ruby
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
end

Both 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:

javascript
// 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:

erb
<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:

  1. Never log the LaTeX source. It may contain sensitive data (names, addresses, financial figures). Log only the status code and a sanitized message.
  2. 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.
  3. Surface LaTeX errors to developers, not end users. The 422 response body from FormatEx includes the pdflatex stderr 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.
  4. 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:

ruby
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
end

Deploying 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 Aptfile with texlive-full
  • A Docker-based buildpack
  • A separate worker dyno running pdflatex
  • Any changes to your heroku.yml or render.yaml

The only deployment step is adding the environment variable:

bash
# 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: false

Your 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.

\end{article}

Back to blog

\related{posts}

One quick thing

We track anonymous usage — page views, feature usage, compilation events — to understand what works and what doesn't. No ads, no personal data, no third-party sharing.

Cookie policy