You ship a blog post. Someone shares it on Twitter. And there it is — a sad, blank rectangle where your preview image should be.
OG images matter more than most developers think. They're the difference between a link that gets clicked and one that gets scrolled past. Yet most Rails apps either have no OG images or use the same generic one for every page.
Let's fix that.
The Problem
Every page on your site needs a unique og:image meta tag. For a blog with 50 posts, that's 50 images. Creating them manually in Figma? Nobody has time for that.
You need to generate them programmatically.
Option 1: DIY with ImageMagick (MiniMagick)
The classic Ruby approach. Generate images server-side with MiniMagick:
# Gemfile
gem 'mini_magick'
# app/services/og_image_generator.rb
class OgImageGenerator
def self.generate(title:, subtitle: nil)
image = MiniMagick::Image.open("app/assets/images/og_template.png")
image.combine_options do |c|
c.gravity "Center"
c.pointsize 48
c.font "Helvetica-Bold"
c.fill "#FFFFFF"
c.annotate "+0-30", word_wrap(title, 30)
if subtitle
c.pointsize 24
c.fill "#CCCCCC"
c.annotate "+0+50", subtitle
end
end
image
end
def self.word_wrap(text, max_chars)
text.scan(/.{1,#{max_chars}}(\s|$)/).map(&:strip).join("\n")
end
end
Pros: Free, no external dependencies, full control.
Cons: Text rendering is primitive. No CSS, no layouts, no gradients, no modern typography. Your images will look like they were made in 2005. Also, ImageMagick has a long history of security vulnerabilities — running it on user-influenced input requires careful sandboxing.
Option 2: HTML + Headless Chrome
Render HTML/CSS as an image using a headless browser:
# Using Grover (Puppeteer wrapper)
# Gemfile
gem 'grover'
# app/services/og_image_generator.rb
class OgImageGenerator
def self.generate(title:, author:, date:)
html = ApplicationController.render(
template: "og_images/show",
layout: false,
assigns: { title: title, author: author, date: date }
)
grover = Grover.new(html,
viewport: { width: 1200, height: 630 },
format: "png",
full_page: false
)
grover.to_png
end
end
<!-- app/views/og_images/show.html.erb -->
<div style="width: 1200px; height: 630px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; padding: 60px;">
<div style="text-align: center; color: white;">
<h1 style="font-size: 56px; margin: 0;"><%= @title %></h1>
<p style="font-size: 24px; opacity: 0.8; margin-top: 20px;">
<%= @author %> · <%= @date %>
</p>
</div>
</div>
Pros: Full CSS power — gradients, custom fonts, flexbox, anything you can do on the web.
Cons: Heavy. You need Chrome/Chromium installed on your server. Memory-hungry (each render spins up a browser instance). Slow (~2-3 seconds per image). On a $5 VPS, you'll feel it.
Option 3: Use an API
If you don't want to manage Chrome on your server, offload the rendering:
# app/services/og_image_generator.rb
class OgImageGenerator
API_URL = "https://rendly-api.fly.dev/api/v1"
def self.generate(title:, author:, date:)
response = HTTP
.auth("Bearer #{ENV['RENDLY_API_KEY']}")
.post("#{API_URL}/screenshots", json: {
html: template(title, author, date),
viewport_width: 1200,
viewport_height: 630,
format: "png"
})
response.body
end
def self.template(title, author, date)
<<~HTML
<div style="width:1200px;height:630px;background:linear-gradient(135deg,#667eea,#764ba2);display:flex;align-items:center;justify-content:center;padding:60px;">
<div style="text-align:center;color:white;">
<h1 style="font-size:56px;margin:0;">#{ERB::Util.html_escape(title)}</h1>
<p style="font-size:24px;opacity:0.8;margin-top:20px;">#{ERB::Util.html_escape(author)} · #{date}</p>
</div>
</div>
HTML
end
end
Pros: No Chrome on your server. Fast. Scales without eating your memory.
Cons: External dependency. Costs money at scale (though most APIs have free tiers — Rendly gives you 100/month free).
Serving the Images
Whichever method you choose, cache aggressively and serve via your layout:
# app/controllers/concerns/og_imageable.rb
module OgImageable
extend ActiveSupport::Concern
def set_og_image(title:, **opts)
cache_key = "og_image/#{Digest::MD5.hexdigest(title)}"
@og_image_url = Rails.cache.fetch(cache_key, expires_in: 7.days) do
blob = OgImageGenerator.generate(title: title, **opts)
# Upload to Active Storage or S3, return URL
upload_og_image(blob)
end
end
end
<!-- app/views/layouts/application.html.erb -->
<meta property="og:image" content="<%= @og_image_url || default_og_image_url %>" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="<%= @og_image_url || default_og_image_url %>" />
Which Should You Choose?
| Approach | Cost | Quality | Complexity |
|---|---|---|---|
| ImageMagick | Free | ⭐⭐ | Low |
| Headless Chrome | Free (+ server resources) | ⭐⭐⭐⭐⭐ | High |
| API | Free tier → paid | ⭐⭐⭐⭐⭐ | Low |
For a side project with a few pages? ImageMagick is fine.
For a content-heavy app where previews matter? Headless Chrome gives you the most control, but an API saves you the ops headache.
I'm biased — I built Rendly specifically because I got tired of managing Puppeteer on $7 VPSes. But honestly, any of these approaches beats the blank rectangle.
What's your approach to OG images? I'd love to hear if I missed a good option.
Top comments (0)