Every blog post, product page, and profile in a Rails app deserves its own Open Graph image, the picture that shows up when someone shares the link on Twitter, LinkedIn, or Slack. The catch is that Ruby's options for generating those images are worse than the equivalents in the JavaScript and PHP worlds. There's no Satori, no first-class templating-to-image story, and the tools that do exist either make you place pixels by hand or run a headless browser next to Puma.
This post walks through the three realistic ways to do it in Rails, then builds out the one that keeps your view layer and your servers clean: rendering an ERB template to a PNG over HTTP, with caching and a background job so it never sits on the request path.
This is a cross-post of an article first published on html2img.com.
The three approaches
| Approach | What runs | Best for |
|---|---|---|
| Image libraries (MiniMagick, ruby-vips) | ImageMagick or libvips on your server | Fixed layouts with very little text |
| Headless Chrome (Grover) | Node and a Chromium binary alongside Puma | Full CSS control, if you can carry the ops cost |
| HTML to Image API | One HTTP call, nothing local | Apps that want CSS layouts without running a browser |
Approach 1: image libraries, and why they hurt
The pure-Ruby route is MiniMagick or ruby-vips. You open a base image and composite text onto it.
require "mini_magick"
image = MiniMagick::Image.open(Rails.root.join("app/assets/images/og-base.png"))
image.combine_options do |c|
c.gravity "NorthWest"
c.pointsize "64"
c.fill "#0F172A"
c.font "Inter-Bold"
c.annotate "+80+80", post.title
end
image.write(Rails.root.join("public/og/#{post.id}.png"))
This works until the text gets interesting. You're positioning every element by hand, with no line wrapping and no idea how wide a string will render until you measure it. You also have to install and reference font files on every machine. For one short line on a fixed background it's fine. The moment you want a title that wraps, an author line, and a logo, you're reimplementing CSS layout in ImageMagick options. Wrong job for the tool.
Approach 2: headless Chrome with Grover
If you want real CSS, the obvious move is to render HTML in a real browser. Grover wraps Puppeteer and turns HTML into a PNG.
# Gemfile
gem "grover"
html = ApplicationController.render(
template: "og/post",
layout: false,
assigns: { post: post }
)
png = Grover.new(html, width: 1200, height: 630).to_png
File.binwrite(Rails.root.join("public/og/#{post.id}.png"), png)
The output is excellent, because it's a browser. The cost is operational. Grover drives Puppeteer, so every machine that renders an image needs Node and a Chromium binary installed next to your Ruby app. On a container that means a much larger image, a few hundred megabytes of Chrome resident whenever it runs, and cold-start latency when the process spins up. You're operating a browser to make a picture.
Approach 3: render an ERB template through an API
The third option keeps the browser-quality rendering but moves it off your infrastructure. You write the OG card as a normal ERB template, render it to an HTML string, and POST that string to an image API. One HTTP call, nothing extra to install.
Start with the key. Add it to your encrypted credentials with rails credentials:edit:
html2img:
api_key: your-key-here
The key goes out as an X-API-Key header on every request. Now build the template. It's a plain view, styled with inline CSS, sized to exactly the dimensions you'll request.
<%# app/views/og/post.html.erb %>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap');
* { margin: 0; box-sizing: border-box; }
body {
width: 1200px; height: 630px; padding: 80px;
display: flex; flex-direction: column; justify-content: space-between;
background: #0F172A; color: #F8FAFC; font-family: 'Inter', sans-serif;
}
.title { font-size: 64px; font-weight: 800; line-height: 1.1; max-width: 1000px; }
.meta { font-size: 26px; color: #94A3B8; }
</style>
</head>
<body>
<div class="title"><%= @post.title %></div>
<div class="meta"><%= @post.author.name %> · <%= @post.published_at.strftime("%-d %B %Y") %></div>
</body>
</html>
Set the width and height on the body to match what you send the API, otherwise the content can shift. Because this renders in a real browser, emoji, web fonts, and full CSS behave exactly as they do in your own tab.
Wrap the call in a plain service object. ApplicationController.render turns the template into a string outside the request cycle, and Faraday posts it.
# app/services/og_image_generator.rb
class OgImageGenerator
ENDPOINT = "https://app.html2img.com/api/html".freeze
class GenerationError < StandardError; end
def initialize(post)
@post = post
end
def call
html = ApplicationController.render(
template: "og/post",
layout: false,
assigns: { post: @post }
)
res = connection.post("") do |req|
req.body = { html: html, width: 1200, height: 630 }.to_json
end
raise GenerationError, res.body unless res.success?
JSON.parse(res.body).fetch("url")
end
private
def connection
@connection ||= Faraday.new(url: ENDPOINT) do |f|
f.headers["Content-Type"] = "application/json"
f.headers["X-API-Key"] = Rails.application.credentials.dig(:html2img, :api_key)
f.options.timeout = 15
end
end
end
The response is JSON with a url pointing at the hosted PNG. Store that on the record rather than proxying the image through your own app.
Generating off the request path
You never want to make an external HTTP call while a user waits for a page, so move generation into a background job.
# app/jobs/generate_og_image_job.rb
class GenerateOgImageJob < ApplicationJob
queue_as :default
def perform(post)
url = OgImageGenerator.new(post).call
post.update_columns(og_image_url: url, og_signature: post.current_og_signature)
end
end
update_columns writes straight to the database without firing callbacks, which matters in a second.
Caching so you don't regenerate on every save
Regenerating the image every time a record is touched wastes calls. The fix is a signature: a hash of only the fields that appear in the image. If the signature hasn't changed, the existing image is still correct.
class AddOgFieldsToPosts < ActiveRecord::Migration[7.1]
def change
add_column :posts, :og_image_url, :string
add_column :posts, :og_signature, :string
end
end
# app/models/post.rb
class Post < ApplicationRecord
belongs_to :author
after_save_commit :enqueue_og_image, if: :og_image_outdated?
def current_og_signature
Digest::SHA1.hexdigest([title, author.name, published_at.to_i].join("|"))
end
private
def og_image_outdated?
og_image_url.blank? || og_signature != current_og_signature
end
def enqueue_og_image
GenerateOgImageJob.perform_later(self)
end
end
Editing a post's body, which isn't part of the card, leaves the signature untouched, so no new image is generated. Change the title and the next save regenerates exactly once. Because the job uses update_columns, storing the new URL and signature doesn't trigger after_save_commit again, so there's no loop.
Wiring up the meta tags
With the URL on the record, the view layer is the easy part. Yield a head block in the layout, then fill it in from the post page with Rails' tag helpers.
<%# app/views/posts/show.html.erb %>
<% content_for :head do %>
<%= tag.meta property: "og:image", content: @post.og_image_url %>
<%= tag.meta name: "twitter:card", content: "summary_large_image" %>
<%= tag.meta name: "twitter:image", content: @post.og_image_url %>
<% end %>
Set twitter:card to summary_large_image so the image renders full width rather than as a thumbnail.
Testing without hitting the API
Stub the HTTP call so tests never make a real request, then assert the job stores what the API returned.
# test/jobs/generate_og_image_job_test.rb
require "test_helper"
class GenerateOgImageJobTest < ActiveJob::TestCase
test "stores the returned image url on the post" do
post = posts(:hello_world)
stub_request(:post, "https://app.html2img.com/api/html")
.to_return(
status: 200,
body: { url: "https://i.html2img.com/abc123.png" }.to_json,
headers: { "Content-Type" => "application/json" }
)
GenerateOgImageJob.perform_now(post)
assert_equal "https://i.html2img.com/abc123.png", post.reload.og_image_url
end
end
That uses WebMock, which most Rails test setups already pull in. The same pattern works in RSpec.
Which to reach for
Use MiniMagick only when the layout is fixed and almost text-free. Reach for Grover when you genuinely need a browser on your own infrastructure for other reasons and the OG image is a side benefit. For everything else, rendering an ERB template through an API keeps your servers free of Chrome, gives you real CSS, and costs a single HTTP call per image. Pair it with the signature cache and a background job and OG images become something you set up once and stop thinking about.
The full version, with a couple of extra notes, is on html2img.com. How are you generating OG images in your Rails apps at the moment? Let me know in the comments.