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,
rails8.1.4,meta-tags2.24.0 andrenderscreenshot1.0.1 on macOS. The views, helper and test below ran in a single-file Rails harness with an in-memoryPostinstead 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&cache_ttl=2592000&expires=1794787200&key_id=rs_pub_x&preset=og_card&url=https%3A%2F%2Fexample.com%2Fog%2Fposts%2Fhello-world%3Fv%3D1792281600&signature=..."> <meta property="og:image:width" content="1200"> <meta property="og:image:height" content="630"> <meta property="og:image:alt" content="Why <b>bold</b> & "quoted" 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 &, 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
- Generate OG Images From HTML: A Production Guide Generate Open Graph images from an HTML template: platform size limits, escaping, signed URLs without redirects, cache-safe expiry and a Next.js example.
- The State of OG Images: A 10,000-Site Crawl We crawled the top 10,000 home pages for og:image and Twitter card tags: adoption, sizes, formats, file weight and broken image URLs, with our method.
- Next.js OG Images: opengraph-image.tsx vs a Route Handler Dynamic OG images in Next.js 16: opengraph-image.tsx, the tags it emits, measured static vs per-request timings, fonts, and when a screenshot route wins.
- OG Image Not Showing? 12 Causes and a Checker Script OG image not showing in Slack, LinkedIn or X? Twelve causes in the order to check them, a tested Node script that finds each one, and the fix for every case.
- OG Image Size: 1200×630, Crops and File Limits The right OG image size is 1200×630 (1.91:1). Platform limits with sources, how many pixels each preview shape crops, and measured file sizes at 1x and 2x.
- Twitter Card Image Size: summary vs summary_large_image X card image sizes: 1200×600 (2:1) for summary_large_image, a square for summary. What 1200×630 and 1200×628 lose to the crop, and the tags that pick the card.