Add Dynamic OG Images to Ruby on Rails Apps
Rails has conventions for almost everything—except social preview images. There is no built-in mechanism for generating og:image tags per page, and most Rails apps end up sharing a single static image across every route. This guide shows you how to build a clean, reusable OG image system using ActionView helpers, content_for, and a URL-based API that eliminates the need for MiniMagick, ruby-vips, or headless Chrome entirely.
Why OG Images Matter for Rails Apps
Every time someone shares a link from your Rails app on Twitter, LinkedIn, Slack, Discord, or iMessage, the platform fetches the page's og:image meta tag and renders a visual preview card. That card is the first thing people see before deciding whether to click.
Pages with custom OG images consistently outperform those without. The data is hard to ignore:
- Twitter/X: Tweets with large image cards see 2–3x more engagement than text-only tweets
- LinkedIn: Posts with images get roughly 2x the click-through rate of posts without
- Slack and Discord: Link previews with images take up more visual space in the feed, making them impossible to scroll past
- iMessage and WhatsApp: Rich link previews with images look intentional and trustworthy; bare URLs look like spam
For a Rails app with blog posts, product pages, or user-generated content, a single static OG image means every share looks identical. A blog post about deployment strategies and a blog post about database indexing produce the same preview card. That is a missed opportunity on every single share.
The Traditional Approach (and Why It Hurts)
Ruby has several image processing libraries, and Rails developers have historically reached for them to generate OG images. Here is what each option actually involves:
MiniMagick / ImageMagick
MiniMagick is a Ruby wrapper around ImageMagick. To generate an OG image, you shell out to convert with a chain of arguments: canvas size, background color, font path, pointsize, gravity, annotate coordinates, and output path. A typical implementation looks something like this:
# This is what you are trying to avoid
image = MiniMagick::Image.open("app/assets/images/og-template.png")
image.combine_options do |c|
c.gravity "Center"
c.pointsize 48
c.font "app/assets/fonts/Inter-Bold.ttf"
c.fill "#FFFFFF"
c.annotate "+0+0", title
end
image.write("tmp/og/#{slug}.png")
This requires ImageMagick installed on your production server (an additional system dependency), font files committed to your repo or installed on the server, text wrapping logic you write yourself, a temp directory for output files, and a way to serve those files (either from disk or upload to S3). It works, but it is 40–80 lines of brittle code that breaks every time you change your design.
ruby-vips (libvips)
Rails 7 ships with image_processing backed by libvips, which is faster and uses less memory than ImageMagick. But the text rendering API is even more manual. You create a text image with Vips::Image.text, composite it onto a background, and export to PNG. The code is lower-level, the documentation is thinner, and you still need libvips installed on your server.
Headless Chrome / Grover / Puppeteer
The most flexible option is rendering HTML to a PNG using headless Chrome. The grover gem makes this possible in Ruby. You write an HTML template, render it with ERB, pass it to Grover, and get a screenshot back. The result looks great because you are literally rendering a web page. But you are also adding a 150–300 MB Chrome binary to your Docker image, spawning a browser process per image, and dealing with 500ms+ render times. On Heroku, it requires a buildpack. On Render or Fly.io, it requires a custom Dockerfile.
The Common Problem
All three approaches share the same fundamental issue: they make image generation your server's responsibility. Your Rails app now has to install system dependencies, manage file I/O, handle errors from native binaries, and deal with the memory overhead of image processing on every request. For something that only matters when a social platform crawls your page, that is a lot of infrastructure.
The alternative: construct a URL string. The image generation happens on someone else's server, the output is cached on a CDN, and your Rails app does nothing except render a <meta> tag.
Using the OGPeek API with Rails
OGPeek generates 1200×630 PNG images from URL parameters. You do not install a gem, run a background job, or touch the filesystem. You build a URL, put it in a meta tag, and you are done.
The base URL structure:
https://todd-agent-prod.web.app/api/v1/og
?title=Your+Page+Title
&subtitle=Optional+Subtitle
&template=gradient
&theme=midnight
&brandColor=%23F59E0B
Parameters:
title— The main text on the image (required)subtitle— Secondary text, typically a description or author nametemplate— Visual layout:gradient,minimal,bold,darktheme— Color scheme:midnight,ocean,sunset,forestbrandColor— Hex color for accent elements (URL-encode the#as%23)
The API returns a PNG image. Social platform crawlers fetch this URL when they encounter it in your og:image meta tag. The response is cached, so subsequent requests for the same parameters are fast.
Step 1: Create the Helper Method
The cleanest way to integrate OGPeek into a Rails app is with an ApplicationHelper method. This keeps URL construction in one place and makes it available to every view.
# app/helpers/application_helper.rb
module ApplicationHelper
OGPEEK_BASE = "https://todd-agent-prod.web.app/api/v1/og"
def og_image_url(title:, subtitle: nil, template: "gradient", theme: "midnight", brand_color: "#F59E0B")
params = {
title: title,
template: template,
theme: theme,
brandColor: brand_color
}
params[:subtitle] = subtitle if subtitle.present?
"#{OGPEEK_BASE}?#{URI.encode_www_form(params)}"
end
end
This method uses URI.encode_www_form from Ruby's standard library. It handles all special character encoding automatically—spaces become +, the # in hex colors becomes %23, ampersands and other characters are escaped correctly. You never have to think about URL encoding again.
Step 2: Wire Up the Layout
Rails layouts support content_for blocks, which let child views inject content into the parent layout. This is the standard Rails pattern for setting per-page meta tags.
<!-- app/views/layouts/application.html.erb -->
<!DOCTYPE html>
<html>
<head>
<title><%= content_for?(:title) ? yield(:title) : "My Rails App" %></title>
<!-- Open Graph tags -->
<meta property="og:title" content="<%= content_for?(:title) ? yield(:title) : 'My Rails App' %>">
<meta property="og:description" content="<%= content_for?(:description) ? yield(:description) : 'Default description' %>">
<meta property="og:image" content="<%= content_for?(:og_image) ? yield(:og_image) : og_image_url(title: 'My Rails App') %>">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:type" content="website">
<meta property="og:url" content="<%= request.original_url %>">
<!-- Twitter Card tags -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="<%= content_for?(:title) ? yield(:title) : 'My Rails App' %>">
<meta name="twitter:description" content="<%= content_for?(:description) ? yield(:description) : 'Default description' %>">
<meta name="twitter:image" content="<%= content_for?(:og_image) ? yield(:og_image) : og_image_url(title: 'My Rails App') %>">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "application" %>
</head>
<body>
<%= yield %>
</body>
</html>
The key line is the og:image tag. If a child view provides a content_for(:og_image) block, it uses that. Otherwise, it falls back to a default image generated from your app name. This means every page in your app gets an OG image automatically, and any page can override it.
Step 3: Set OG Images in Views
Now any view can set its own OG image by calling content_for at the top of the template:
<!-- app/views/posts/show.html.erb -->
<% content_for(:title) { @post.title } %>
<% content_for(:description) { truncate(@post.body, length: 160) } %>
<% content_for(:og_image) { og_image_url(title: @post.title, subtitle: "by #{@post.author.name}") } %>
<article>
<h1><%= @post.title %></h1>
<p class="byline">by <%= @post.author.name %></p>
<div class="content">
<%= @post.body.html_safe %>
</div>
</article>
That is the entire integration for a blog post. Three lines of content_for at the top, and the post has a unique title, description, and OG image in its social preview card.
Step 4: Per-Model OG Images
For apps with multiple content types, you can add og_image_url logic directly to your models or presenters. This keeps the OG image definition close to the data it represents.
Blog Posts
# app/models/post.rb
class Post < ApplicationRecord
def og_image
params = URI.encode_www_form(
title: title,
subtitle: "#{reading_time} min read · #{category.name}",
template: "gradient",
theme: "midnight",
brandColor: "#F59E0B"
)
"https://todd-agent-prod.web.app/api/v1/og?#{params}"
end
end
Products
# app/models/product.rb
class Product < ApplicationRecord
def og_image
params = URI.encode_www_form(
title: name,
subtitle: "$#{price} · #{category.name}",
template: "bold",
theme: "midnight",
brandColor: "#10B981"
)
"https://todd-agent-prod.web.app/api/v1/og?#{params}"
end
end
User Profiles
# app/models/user.rb
class User < ApplicationRecord
def og_image
params = URI.encode_www_form(
title: display_name,
subtitle: "#{posts_count} posts · Member since #{created_at.year}",
template: "minimal",
theme: "ocean",
brandColor: "#6366F1"
)
"https://todd-agent-prod.web.app/api/v1/og?#{params}"
end
end
Then in your views, the content_for call becomes a single method call:
<% content_for(:og_image) { @post.og_image } %>
<% content_for(:og_image) { @product.og_image } %>
<% content_for(:og_image) { @user.og_image } %>
Each model knows how to represent itself as a social preview image. The view just asks for it.
Alternative: Controller Concern
If you prefer keeping OG logic out of models, a controller concern works well. This approach sets instance variables that the layout reads automatically:
# app/controllers/concerns/og_imageable.rb
module OgImageable
extend ActiveSupport::Concern
included do
helper_method :og_image_tag_url
end
private
def set_og_image(title:, subtitle: nil, template: "gradient", theme: "midnight")
@og_image_url = helpers.og_image_url(
title: title,
subtitle: subtitle,
template: template,
theme: theme
)
end
def og_image_tag_url
@og_image_url || helpers.og_image_url(title: "My Rails App")
end
end
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
include OgImageable
def show
@post = Post.find(params[:id])
set_og_image(
title: @post.title,
subtitle: "by #{@post.author.name}"
)
end
end
Then update your layout to use the concern's helper instead of content_for:
<meta property="og:image" content="<%= og_image_tag_url %>">
Choose whichever pattern fits your app's conventions. Both achieve the same result: every page gets a unique, dynamically generated OG image without any image processing on your server.
Comparison: Traditional vs. API Approach
| Aspect | MiniMagick / ruby-vips | OGPeek API |
|---|---|---|
| System dependencies | ImageMagick or libvips | None |
| Gems required | mini_magick or image_processing | None |
| Lines of code | 40–100+ per template | 5–10 total |
| Server memory | 50–200 MB per render | 0 (offloaded) |
| Font management | TTF files on server | Handled by API |
| Design changes | Edit code, redeploy | Change URL params |
| CDN caching | You configure it | Built-in |
Caching Strategies
With a URL-based API, caching works differently than with server-generated images. The image itself is cached on the API's CDN—you do not need to cache the PNG on your side. But there are still optimizations worth considering.
Cache the URL String
If building the OG image URL involves a database query (e.g., fetching a category name or author), you can cache the assembled URL:
class Post < ApplicationRecord
def og_image
Rails.cache.fetch("post/#{id}/og_image", expires_in: 1.hour) do
params = URI.encode_www_form(
title: title,
subtitle: "#{reading_time} min read",
template: "gradient",
theme: "midnight",
brandColor: "#F59E0B"
)
"https://todd-agent-prod.web.app/api/v1/og?#{params}"
end
end
end
In practice, this is rarely necessary. Building a URL from a model's attributes is fast—microseconds, not milliseconds. The cache is more useful if you are doing expensive string processing or multiple joins to assemble the subtitle.
Russian Doll Caching with Fragments
If you use Rails fragment caching in your layout, the OG meta tags will be cached along with the rest of the page head. This means the URL is computed once and served from cache on subsequent requests:
<% cache @post do %>
<meta property="og:image" content="<%= @post.og_image %>">
<% end %>
HTTP Caching Headers
Social platform crawlers respect HTTP caching headers. When you use stale? or expires_in in your controller, the crawler may cache the page (and its OG tags) for the duration you specify. This reduces re-crawl frequency but also means updates to your OG image take longer to propagate.
Testing OG Images
It is worth verifying that your OG tags render correctly before pushing to production. Here are three approaches.
Integration Tests
Use Rails system tests or request specs to check the meta tags in the rendered HTML:
# test/integration/og_image_test.rb
require "test_helper"
class OgImageTest < ActionDispatch::IntegrationTest
test "post show page has unique og:image" do
post = posts(:published)
get post_path(post)
assert_response :success
assert_select 'meta[property="og:image"]' do |elements|
og_url = elements.first["content"]
assert_includes og_url, "todd-agent-prod.web.app/api/v1/og"
assert_includes og_url, URI.encode_www_form_component(post.title)
end
end
test "og:image dimensions are set" do
get post_path(posts(:published))
assert_select 'meta[property="og:image:width"][content="1200"]'
assert_select 'meta[property="og:image:height"][content="630"]'
end
end
RSpec Example
# spec/requests/posts_spec.rb
RSpec.describe "Posts", type: :request do
describe "GET /posts/:id" do
let(:post) { create(:post, title: "Rails OG Images Guide") }
it "includes a dynamic og:image meta tag" do
get post_path(post)
expect(response.body).to include(
'property="og:image"'
)
expect(response.body).to include(
"todd-agent-prod.web.app/api/v1/og"
)
expect(response.body).to include(
URI.encode_www_form_component("Rails OG Images Guide")
)
end
end
end
Manual Validation
After deploying, test your URLs with these platform-specific tools:
- Twitter Card Validator: cards-dev.twitter.com/validator
- Facebook Sharing Debugger: developers.facebook.com/tools/debug
- LinkedIn Post Inspector: linkedin.com/post-inspector
- Open Graph Preview: Paste the OGPeek URL directly in your browser to see the generated image
Tip: Social platforms cache OG images aggressively. If you update a post's title and the preview does not change, use the Facebook Sharing Debugger's "Scrape Again" button or append a cache-busting query parameter like &v=2 to force a refresh.
Production Checklist
Before shipping your OG image integration, verify these items:
- Every page has an og:image tag. The layout fallback ensures this, but check that it renders a valid URL.
- Image dimensions are declared. Include
og:image:width(1200) andog:image:height(630) so platforms do not have to fetch the image just to get its size. - Twitter card type is set. Use
summary_large_imagefor the best visual impact. - Title length is under 60 characters. Longer titles get truncated in the generated image. If your post titles are long, pass a shortened version to the OG image helper.
- Special characters are encoded.
URI.encode_www_formhandles this, but double-check titles with ampersands, quotes, or emoji. - The og:url tag uses the canonical URL. Use
request.original_urlor a canonical URL helper, not a relative path.
Get 50 Free OG Images per Day
OGPeek generates production-ready 1200×630 social preview images from a single URL. No API key required for the free tier. Works with any Rails app, any Ruby version.
Start Free →Going Further
Once the basic integration is in place, here are a few ways to extend it:
- A/B test OG images: Use different templates or themes for different post categories and track which ones drive more clicks from social.
- Dynamic brand colors: If your app has user-customizable themes, pass the user's brand color to the OG image API so their profile shares match their branding.
- Localized images: For internationalized apps, pass the localized title to the API. The image text matches the page language automatically.
- Sitemap integration: Include OG image URLs in your XML sitemap using the
image:imageextension. This helps Google discover your social preview images and can improve image search visibility.
Frequently Asked Questions
How do I add OG image meta tags to a Rails app?
Create a helper method in ApplicationHelper that builds an OG image URL from a title and optional subtitle. Use content_for and yield in your application layout to inject the og:image meta tag into the head section. Each view or controller action sets the title, and the helper generates the correct image URL automatically.
Do I need MiniMagick or ImageMagick to generate OG images in Rails?
No. With a URL-based API like OGPeek, you only construct a URL string using URI.encode_www_form. The image generation happens on the API server. MiniMagick and ruby-vips require installing native system libraries (ImageMagick or libvips) on your production server, managing memory, and writing image composition code—all of which you can skip with an API approach.
Can I generate unique OG images for each page in my Rails app?
Yes. By using content_for(:og_image) in your views or setting instance variables in your controllers, each page can pass its own title and subtitle to the OG image helper. Blog posts, product pages, user profiles—every route gets a unique social preview image that matches its content.
How do I cache OG images in Rails to avoid slow page loads?
OG images from a URL-based API do not affect your page load speed because the browser does not fetch them during rendering—social platforms fetch them when someone shares the URL. On the Rails side, you can use Rails.cache.fetch to cache the generated URL string if building it involves database queries, but the URL construction is typically so fast that caching is unnecessary.
Does OGPeek work with Rails 7, Rails 8, and Turbo?
Yes. OGPeek is a URL-based API that generates images server-side, so it is independent of your frontend framework. It works with Rails 7, Rails 8, Hotwire, Turbo, Stimulus, and any other JavaScript setup. The og:image meta tag is rendered in your layout's head section during the initial server response, which is exactly what social platform crawlers read.