Dynamic OG Images in Rails

Published: (June 10, 2026 at 07:26 AM EDT)
5 min read
Source: Dev.to

Source: Dev.to

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.

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

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. 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. 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.

  @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; }




 · 

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

Set twitter:card to summary_large_image so the image renders full width rather than as a thumbnail. 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 “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. 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.

0 views
Back to Blog

Related posts

Read more »