DEV Community

Mack
Mack

Posted on

How to Generate Open Graph Images Automatically in Ruby on Rails

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
<!-- 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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
<!-- 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 %>" />
Enter fullscreen mode Exit fullscreen mode

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)