Dynamic OG images in Rails: Active Job, Ferrum or an API

Per-record og:image in Rails: screenshot an ERB view with Ferrum, call an image API from Active Job, or sign the image URL in a helper.

Published October 24, 2026 · 5 min read

A dynamic Open Graph image in Rails is one image per record — a post, a product, a release — whose absolute URL goes into <meta property="og:image">. The pattern that holds up in production: an after_commit callback enqueues an Active Job, the job produces the image (with a headless Chrome, an image library, or an API), and stores the URL on the record; the layout prints it with a fallback for records that have not been rendered yet.

Pick how the pixels get made

Ferrum / Grover ruby-vips / MiniMagick HTTP API
Layout ERB view, HTML + CSS Drawing calls HTML + CSS template
On your servers Chrome (Grover: also Node.js) libvips or ImageMagick Nothing extra
Cost per render 1–3 s, a Chrome process Milliseconds About a second, elsewhere
Good for Teams already running Chrome A logo and a line of text HTML cards without a browser fleet

The callback, the column and the layout below are the same for all three, so you can start with one and switch later.

The model and the callback

# db/migrate/20261024000000_add_og_image_url_to_posts.rb
class AddOgImageUrlToPosts < ActiveRecord::Migration[8.0]
  def change
    add_column :posts, :og_image_url, :string
  end
end

# app/models/post.rb
class Post < ApplicationRecord
  belongs_to :author

  after_commit :render_og_image, on: %i[create update], if: :og_fields_changed?

  private

  def og_fields_changed?
    published? && (saved_change_to_title? || saved_change_to_author_id? || saved_change_to_published_at?)
  end

  def render_og_image
    GenerateOgImageJob.perform_later(self)
  end
end

after_commit instead of after_save: the job may start on another worker before the transaction commits, find the old row, and render the old title. And the saved_change_to_*? check keeps a typo fix in the body from re-rendering the image.

Option 1: screenshot an ERB view with Ferrum

Ferrum talks to Chrome over the DevTools protocol, so there is no Node.js involved — only a Chrome or Chromium binary on the job workers.

# app/jobs/generate_og_image_job.rb
class GenerateOgImageJob < ApplicationJob
  queue_as :default

  def perform(post)
    html = ApplicationController.render(template: "og/post", layout: false, assigns: { post: post })
    browser = Ferrum::Browser.new(window_size: [1200, 630], timeout: 20)
    browser.goto("data:text/html;base64,#{Base64.strict_encode64(html)}")
    browser.network.wait_for_idle
    png = browser.screenshot(format: "png", encoding: :binary)
    browser.quit

    key = "og/#{post.id}-#{Digest::SHA1.hexdigest(post.title)[0, 8]}.png"
    blob = ActiveStorage::Blob.create_and_upload!(io: StringIO.new(png), filename: key, content_type: "image/png")
    post.update_column(:og_image_url, Rails.application.routes.url_helpers.rails_blob_url(blob))
  end
end

The view it renders is an ordinary ERB template sized to the card, with the font embedded so the result does not depend on what is installed on the worker:

<%# app/views/og/post.html.erb %>
<!doctype html>
<html>
<head>
  <style>
    @font-face { font-family: Inter; font-weight: 700; src: url(data:font/ttf;base64,<%= Base64.strict_encode64(File.binread(Rails.root.join("app/assets/fonts/Inter-Bold.ttf"))) %>); }
    body { margin: 0; width: 1200px; height: 630px; display: flex; flex-direction: column; justify-content: space-between;
           padding: 72px; box-sizing: border-box; background: #111827; color: #f9fafb; font-family: Inter, sans-serif; }
    h1 { font-size: 64px; line-height: 1.1; margin: 0; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
    footer { font-size: 28px; color: #9ca3af; }
  </style>
</head>
<body>
  <h1><%= @post.title %></h1>
  <footer><%= @post.author.name %> · yourblog.com</footer>
</body>
</html>

-webkit-line-clamp cuts a very long title after four lines instead of letting it run off the card. update_column writes without callbacks, so the job does not enqueue itself again. The hash in the file name changes the URL when the title changes — platforms cache previews by image URL, and an overwritten file with the same name tends to keep showing the old picture. If you prefer Puppeteer, Grover does the same with Grover.new(html, viewport: { width: 1200, height: 630 }).to_png, but needs Node.js next to Ruby.

Option 2: an image API from Active Job

Here the template is HTML and CSS too, but Chrome runs elsewhere and the job sends JSON. The example uses Shareglint's built-in blog-og template:

# app/jobs/generate_og_image_job.rb
require "net/http"

class GenerateOgImageJob < ApplicationJob
  queue_as :default
  retry_on Net::OpenTimeout, Net::ReadTimeout, wait: :polynomially_longer, attempts: 3

  ENDPOINT = URI("https://shareglint.com/v1/images?wait=true")

  def perform(post)
    request = Net::HTTP::Post.new(ENDPOINT)
    request["Authorization"] = "Bearer #{Rails.application.credentials.og_api_key}"
    request["Content-Type"] = "application/json"
    request.body = {
      template: "blog-og",
      modifications: {
        title: post.title,
        author: post.author.name,
        date: post.published_at.to_date.iso8601,
        site: "yourblog.com"
      }
    }.to_json

    response = Net::HTTP.start(ENDPOINT.host, ENDPOINT.port, use_ssl: true, read_timeout: 30) do |http|
      http.request(request)
    end
    response.value # raises on anything that is not 2xx

    post.update_column(:og_image_url, JSON.parse(response.body).fetch("url"))
  end
end

wait: :polynomially_longer is the Rails 7.1+ name for the growing back-off (Active Job docs). Retries are cheap here: the same fields return the already rendered file without using quota. A render that runs past the sync window answers 503 with the render id; let the job retry.

Option 3: no job, a signed URL in a helper

A signed URL renders on its first request and then redirects to the cached file, so you can print it straight into the layout and skip the job and the column. The signing secret stays in credentials; the URL carries only the signature.

# app/helpers/og_image_helper.rb
require "openssl"
require "erb"

module OgImageHelper
  def og_image_url(template, **fields)
    path = "/i/#{Rails.application.credentials.og_account_id}/#{template}.png"
    query = fields.map { |k, v| [k.to_s, v.to_s] }.sort
                  .map { |k, v| "#{ERB::Util.url_encode(k)}=#{ERB::Util.url_encode(v)}" }
                  .join("&")
    s = OpenSSL::HMAC.hexdigest("SHA256", Rails.application.credentials.og_signing_secret, "#{path}?#{query}")[0, 32]

    "https://shareglint.com#{path}?#{query}&s=#{s}"
  end
end

ERB::Util.url_encode leaves only A–Z a–z 0–9 - _ . ~ unescaped — RFC 3986, the encoding the signature uses. The steps and a test vector are in the API docs.

The layout

With the meta-tags gem:

# app/controllers/posts_controller.rb
def show
  @post = Post.find(params[:id])
  set_meta_tags title: @post.title,
                og: { title: @post.title, image: @post.og_image_url || helpers.image_url("og-default.png") },
                twitter: { card: "summary_large_image" }
end
<%# app/views/layouts/application.html.erb, inside <head> %>
<%= display_meta_tags site: "Your blog" %>

Without the gem, a content_for :head block with plain <meta property="og:image" content="…"> tags does the same. Either way the URL must be absolute — image_url, not image_path — because crawlers do not resolve relative paths against your page.

Test it

# test/jobs/generate_og_image_job_test.rb
require "test_helper"
require "webmock/minitest"

class GenerateOgImageJobTest < ActiveJob::TestCase
  test "stores the rendered url" do
    stub_request(:post, %r{/v1/images}).to_return(
      body: { status: "completed", url: "https://cdn.test/og.png" }.to_json,
      headers: { "Content-Type" => "application/json" }
    )

    post = posts(:published)
    GenerateOgImageJob.perform_now(post)

    assert_equal "https://cdn.test/og.png", post.reload.og_image_url
  end
end

Then check one real page with LinkedIn's Post Inspector or Facebook's Sharing Debugger — they show the exact image each platform picked.

FAQ

How do I set og:image in Rails?

Print a meta tag in the layout's head from a content_for block or an instance variable, or use the meta-tags gem: set_meta_tags og: { image: url } in the controller and display_meta_tags in the layout. The URL must be absolute.

Should the image be generated in the controller?

No. Generate it in an Active Job enqueued from after_commit and store the URL on the record. Rendering in the request makes the first visitor and the link-preview crawler wait for a browser to start.

Which Ruby gem renders HTML to an image?

Ferrum drives Chrome over the DevTools protocol without Node.js; Grover wraps Puppeteer and needs Node.js. Both need Chrome on the machine that runs the job. ruby-vips or MiniMagick draw images without a browser but without HTML layout.

How do I keep old previews from showing after a title change?

Change the image URL when the content changes. Platforms cache by URL, so a new file name or a different signed URL makes them fetch the new image; overwriting the same file usually does not.