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.