GUIDE · MAR 2026

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.

OG image preview for this article generated by OGPeek

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:

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:

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:

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:

  1. Every page has an og:image tag. The layout fallback ensures this, but check that it renders a valid URL.
  2. Image dimensions are declared. Include og:image:width (1200) and og:image:height (630) so platforms do not have to fetch the image just to get its size.
  3. Twitter card type is set. Use summary_large_image for the best visual impact.
  4. 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.
  5. Special characters are encoded. URI.encode_www_form handles this, but double-check titles with ampersands, quotes, or emoji.
  6. The og:url tag uses the canonical URL. Use request.original_url or 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:

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.