· Tutorials · By Simon Chiu

Rails OG Images: meta-tags, an ERB Card and Signed URLs

Dynamic Open Graph images in Rails 8: an ERB card route, the meta-tags gem, a signed og:image helper, Minitest tests and what allow_browser does to crawlers.

To add dynamic OG images to a Rails app, render each card as an ordinary ERB view at 1200×630, then point og:image at a signed screenshot URL of that view. A crawler that fetches the URL gets a PNG rendered by Chromium from your own HTML and CSS. The meta-tags gem writes the tags, and a small helper signs the URL with the RenderScreenshot Ruby SDK:

<%# app/views/posts/show.html.erb %>
<% set_meta_tags title: @post.title,
                 og: {
                   title: @post.title,
                   type: 'article',
                   url: post_url(@post),
                   image: { _: og_image_url_for(@post), width: 1200, height: 630, alt: "#{@post.title}, on example.com" }
                 },
                 twitter: { card: 'summary_large_image' } %>

That is the whole change to a post page. The rest of this tutorial builds the three pieces behind it (the card route, the helper and the layout), adds a Minitest test, and covers the Rails-specific problems: allow_browser, missing hosts, CSP and authentication filters. The general approach, with platform sizes and expiry strategy explained in depth, is in the guide to generating OG images from HTML.

Tested on 2026-10-18 with Ruby 4.0.7, rails 8.1.4, meta-tags 2.24.0 and renderscreenshot 1.0.1 on macOS. The views, helper and test below ran in a single-file Rails harness with an in-memory Post instead of Active Record; 8 tests, 20 assertions, 0 failures. The card HTML was rendered with local Chromium ([email protected]) to check the layout. The signed URL was generated and inspected, not fetched. The SDK's signature matched the reference vector in our signed URL documentation.

How the pieces fit

sequenceDiagram
    participant C as Crawler
    participant R as Your Rails app
    participant RS as RenderScreenshot
    C->>R: GET /blog/hello-world
    R-->>C: HTML, og:image = signed URL of /og/posts/hello-world?v=...
    C->>RS: GET /v1/screenshot?...&signature=...
    RS->>R: GET /og/posts/hello-world?v=... (Chromium, 1200x630)
    R-->>RS: ERB card
    RS-->>C: PNG

Your app serves two things: the post page with tags, and the card page. The API loads the card in a browser and returns the image. Nothing is stored in Active Storage, and no job runs.

Step 1: install and configure

# Gemfile
gem 'meta-tags'
gem 'renderscreenshot'

Store the keys in credentials (bin/rails credentials:edit). A signing key pair has a public key ID (rs_pub_) and a secret key (rs_secret_); create one under API Keys in the dashboard. The secret never leaves your server; only the public ID and a signature go into the URL.

renderscreenshot:
  api_key: rs_live_...
  public_key_id: rs_pub_...
  secret_key: rs_secret_...

Signed URLs need an absolute card URL, so tell Rails its public host:

# config/environments/production.rb
config.action_controller.default_url_options = { host: 'example.com', protocol: 'https' }

Step 2: the card route and its layout

The card gets its own controller and layout, so none of your site's navigation, scripts or cookie banner end up in the image.

# config/routes.rb
resources :posts, only: :show, path: 'blog'
get 'og/posts/:id' => 'og_cards#show', as: :og_card
# app/controllers/og_cards_controller.rb
class OgCardsController < ApplicationController
  layout 'og_card'

  def show
    @post = Post.find_by!(slug: params[:id])
    expires_in 5.minutes, public: true
  end
end

The route takes a post identifier, not a title (here the slug: define Post#to_param to return it so og_card_url(post) builds /og/posts/<slug>). A card URL can only ever show a real post, so nobody can craft a link that puts their own text on an image under your domain.

<%# app/views/layouts/og_card.html.erb %>
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="robots" content="noindex">
    <style>
      * { box-sizing: border-box; }
      html, body { margin: 0; width: 1200px; height: 630px; overflow: hidden; }
      body {
        display: grid; grid-template-rows: auto 1fr auto; padding: 64px 80px;
        background: #18181b; color: #fafafa; font-family: system-ui, sans-serif;
      }
      .section { font-size: 26px; color: #fca5a5; text-transform: uppercase; letter-spacing: .05em; }
      h1 {
        margin: 0; align-self: center; font-size: 66px; line-height: 1.08; text-wrap: balance;
        display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden;
      }
      .byline { font-size: 26px; color: #d4d4d8; }
    </style>
  </head>
  <body><%= yield %></body>
</html>
<%# app/views/og_cards/show.html.erb %>
<div class="section"><%= @post.section %></div>
<h1><%= @post.title %></h1>
<div class="byline"><%= @post.author %> · <%= l(@post.published_on, format: :long) %></div>

ERB's <%= %> escapes output, which is the reason this approach is safe with titles from a CMS. Our test post was titled Why <b>bold</b> & "quoted" titles break naive OG templates. The card rendered those characters as text, and the test asserts there is no <b> element in the h1. Do not call raw or html_safe on anything in this view.

The noindex tag keeps card pages out of search results; the robots.txt file must still allow them, because the renderer needs to load them and some preview crawlers check robots.txt for the image path.

Because the card is a normal view, you can use what a browser supports: CSS grid, text-wrap: balance, line clamping, web fonts. You can also link your app's own stylesheet with stylesheet_link_tag instead of inlining CSS. The renderer loads the card from your public URL, so relative asset paths resolve normally; the asset only has to be publicly reachable.

Step 3: the signing helper

# app/helpers/og_image_helper.rb
module OgImageHelper
  OG_CACHE_TTL = 30.days.to_i # the longest cache_ttl; a card changes only when its version changes

  # Monday 00:00 UTC of the current week, plus 28 days. Every render in the same week
  # signs the same URL, and the URL always has 21 to 28 days left (the API allows 30).
  def og_image_expiry(now = Time.now.utc)
    now.beginning_of_week(:monday).beginning_of_day + 28.days
  end

  def og_image_url_for(post)
    card = og_card_url(post, v: post.updated_at.to_i)
    options = RenderScreenshot::TakeOptions
      .url(card)
      .preset('og_card')
      .cache_available
      .cache_ttl(OG_CACHE_TTL)
    og_image_client.generate_url(options, expires_at: og_image_expiry)
  end

  private

  def og_image_client
    @og_image_client ||= RenderScreenshot::Client.new(
      Rails.application.credentials.dig(:renderscreenshot, :api_key) || ENV.fetch('RS_API_KEY'),
      signing_key: Rails.application.credentials.dig(:renderscreenshot, :secret_key) || ENV.fetch('RS_SECRET_KEY'),
      public_key_id: Rails.application.credentials.dig(:renderscreenshot, :public_key_id) || ENV.fetch('RS_PUBLIC_KEY_ID')
    )
  end
end

Each option has a reason:

Option Why
v: post.updated_at.to_i Editing a post changes the card URL, so the cached render is not reused and platforms see a new image URL
preset('og_card') 1200×630 viewport, PNG; see presets
cache_available A repeat fetch inside the TTL reuses the earlier render for 0 credits instead of rendering again
cache_ttl(30.days) The card only changes when v changes, so keep renders as long as allowed
weekly expires_at The URL is stable for a week; signed URLs must expire within 30 days (signed URLs)

generate_url puts only parameters that GET /v1/screenshot accepts into the signed URL. In SDK 1.0.1, cache_available and cache_ttl are among them; options with no GET form, such as wait_for_selector, are dropped without an error. Never append a parameter to the returned string: the signature covers the whole query, and the API answers invalid_signature.

Step 4: the layout

<%# app/views/layouts/application.html.erb %>
<!DOCTYPE html>
<html>
  <head>
    <%= display_meta_tags site: 'Example', reverse: true %>
    <%# csrf_meta_tags, stylesheets and scripts follow %>
  </head>
  <body><%= yield %></body>
</html>

Put display_meta_tags first in <head>, before stylesheets and scripts, so crawlers that read only the start of the document still find the tags. This is the image part of what the harness rendered for the test post (signature shortened):

<meta property="og:image" content="https://api.renderscreenshot.com/v1/screenshot?cache_available=true&amp;cache_ttl=2592000&amp;expires=1794787200&amp;key_id=rs_pub_x&amp;preset=og_card&amp;url=https%3A%2F%2Fexample.com%2Fog%2Fposts%2Fhello-world%3Fv%3D1792281600&amp;signature=...">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="Why &lt;b&gt;bold&lt;/b&gt; &amp; &quot;quoted&quot; titles break naive OG templates, on example.com">
<meta name="twitter:card" content="summary_large_image">

meta-tags wrote property= for Open Graph and name= for the Twitter tag, escaped the title, and turned each & in the URL into &amp;, which crawlers decode back. We did not set twitter:image: X falls back to og:image, and a 1200×630 image loses only 15 pixels top and bottom in its 2:1 card. The Twitter card size guide explains when a separate X image is worth it.

Step 5: test it with Minitest

The test checks what a crawler depends on: an absolute signed URL, the right preset, a card URL with a version, an expiry inside 30 days, and the size, alt and card-type tags. A second test checks the escaping.

# test/integration/og_image_test.rb
require 'test_helper'

class OgImageTest < ActionDispatch::IntegrationTest
  CHROME = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' \
           '(KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36'

  test 'post page has an absolute signed og:image with size, alt and twitter card' do
    get '/blog/hello-world', headers: { 'User-Agent' => CHROME }
    assert_response :success

    image = css_select('meta[property="og:image"]').first['content']
    assert_match %r{\Ahttps://api\.renderscreenshot\.com/v1/screenshot\?}, image
    query = Rack::Utils.parse_query(URI(image).query)
    assert_equal 'og_card', query['preset']
    assert_equal 'true', query['cache_available']
    assert_match %r{\Ahttps://example\.com/og/posts/hello-world\?v=\d+\z}, query['url']
    assert_operator query['expires'].to_i, :<=, 30.days.from_now.to_i

    assert_select 'meta[property="og:image:width"][content="1200"]'
    assert_select 'meta[property="og:image:height"][content="630"]'
    assert_select 'meta[property="og:image:alt"]'
    assert_select 'meta[name="twitter:card"][content="summary_large_image"]'
  end

  test 'card escapes the title' do
    get '/og/posts/hello-world', params: { v: 1 }, headers: { 'User-Agent' => CHROME }
    assert_response :success
    assert_select 'h1 b', count: 0
  end
end

The tests need the three credentials (or the RS_* environment variables) in the test environment, but they make no network calls: signing is local. Use throwaway values in CI. The User-Agent header matters because of the next section.

Rails 8's allow_browser and preview crawlers

New Rails 8 apps put allow_browser versions: :modern in ApplicationController. It answers old browsers with a 406 page. A crawler blocked that way would never see your tags, so we checked the common preview crawlers against it:

User agent Response from a controller with allow_browser versions: :modern
facebookexternalhit/1.1 200
Twitterbot/1.0 200
LinkedInBot/1.0 200
Slackbot-LinkExpanding 1.0 200
Discordbot/2.0 200
Safari 15.6 on macOS blocked

The crawlers pass because allow_browser blocks only browsers it recognises with a version below the minimum. In our harness the blocked request ended in a 500, because the harness has no public/406-unsupported-browser.html; a generated Rails app ships that file and answers 406. The renderer itself is a current Chromium and passes too. If you tighten allow_browser with a custom block, re-run this check: integration tests with no User-Agent header do not cover it.

Warm the card when a post is published

The first crawler fetch of a new card renders it, and some unfurlers have short time budgets. You can render it before anyone shares the link by fetching the signed URL once after publishing. With cache_available signed in, later fetches inside the TTL reuse that render.

# app/jobs/warm_og_image_job.rb
class WarmOgImageJob < ApplicationJob
  include Rails.application.routes.url_helpers
  include OgImageHelper

  def perform(post)
    response = Net::HTTP.get_response(URI(og_image_url_for(post)))
    raise "og image warm-up failed: HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
  end
end

Jobs build URLs from Rails.application.routes.default_url_options, so set the host there as well. Enqueue it from an after_commit callback when a post is published or its title changes. The request is a real render and uses one credit, which the first crawler fetch would have used anyway. This job was not run for this tutorial, because running it calls the live API.

Other ways to make the image in Ruby

A screenshot of an ERB view is not the only option. Pick by what you are willing to run:

Approach What you maintain Trade-off
Static image per post, uploaded by an editor Nothing technical Someone has to make every image
Draw text with ruby-vips or ImageMagick Font files, text wrapping and layout code in Ruby No browser, but you re-implement line breaks and clamping by hand
Chromium in your app with Ferrum or Grover A browser on your servers, its memory and its fonts Full control; the browser runs next to your web processes
Signed screenshot URL (this tutorial) One ERB view and a helper A third-party service in the path of each first render

If you already run Chromium for other work, the Ferrum vs Grover comparison covers the in-process route. The card view and the tags in this tutorial stay the same whichever renderer you choose; only the helper changes.

Troubleshooting

Symptom Cause Fix
ArgumentError: Missing host to link to! og_card_url has no host Set default_url_options with host and protocol
og:image points at http:// Protocol not set Add protocol: 'https' to default_url_options
Card shows a login page before_action :authenticate_user! (or similar) applies to OgCardsController skip_before_action for the card controller, or inherit from a public base controller
Card has no styles A strict Content Security Policy blocks the inline <style> Exempt the card controller, or serve the CSS as a stylesheet from an allowed source
Card shows your cookie banner or chat widget Card uses the application layout Give the card controller its own layout
KeyError for RS_SECRET_KEY Credentials missing in that environment Add them to credentials, or set the variables
invalid_signature from the API A parameter added to the URL after signing Change options before generate_url, never after
expired_signature on old shares Page cached as HTML for longer than three weeks Expire page caches within three weeks, or store the image
Old title on the card updated_at did not change, so v did not change Touch the post when the title changes; re-scrape on the platform

If the image still does not appear in a preview, the cause is often outside Rails. The twelve causes of a missing OG image come with a checker script that finds most of them. For capture jobs, Active Storage and PDFs in Rails, see the Rails screenshot API page. To try the card route on your own posts, create a free account with 150 credits a month and a signing key pair.

Last tested 2026-10-18.

Related in this topic