rails og image
Rails OG image: static file, Active Storage, or the API
Use this page when a Rails app needs an Open Graph image per record â a blog post, a product, a profile â and you do not want to design each card by hand. It compares a static image in the layout, generating the file yourself and storing it with Active Storage, and calling the hosted OG Pilot API and caching the result in Active Storage.
Get an API key for the job below, or install the MCP server so an agent can call generate_og_image for a public URL. The short path, where the gem returns a CDN URL and you do not store the bytes, is the Rails OG image guide. Also see the OG image API comparison, the og:image size cheat sheet, the Open Graph debugger, the API docs, and generate one image in the browser.
Three ways to ship the card
A static image in the layout is the right call when one artwork represents the whole site. Generating the file yourself and attaching it with Active Storage is the right call when each record needs its own pixels and you want to own the drawing code. Calling the hosted API and caching the result in Active Storage is the right call when each record needs its own card and you would rather not run an image library or a headless browser.
| Approach | Setup effort | Per-request cost / latency | Caching | Maintenance | Dynamic data |
|---|---|---|---|---|---|
| Static file in the layout | One file in public/ and one meta tag in the layout. | No render on the request. The server sends a file that already exists. This page does not publish a millisecond figure. | The file stays until you replace it. Social platforms cache by URL, so a new filename is what makes them fetch again. | You replace the artwork by hand. Every URL shares it unless you add more files. | No. The same pixels for every post, product, or profile. |
| Your renderer + Active Storage | A has_one_attached, a job, and an image library or a headless browser you operate. This page does not name a rendering gem. | The HTML request can serve the stored file. The render runs in the job. This page does not publish that render's time or host cost. | You replace the blob when the card fields change. Crawlers keep the previous preview until og:image points at a new URL and they fetch it. | You own fonts, layout, emoji, and the process that draws the file. | Yes, when the job reads the record and draws those fields. |
| OG Pilot API + Active Storage | The og_pilot_ruby gem, an API key, a verified domain, and a job that downloads the JSON url and attaches the bytes. | The HTML request serves the stored file. The API call runs in the job. The plan catalog lists Starter at $5/month ($50/year, 10,000-image quota), Builder at $49/month ($99/year, 100,000-image quota), and Growth at $99/month ($199/year, unlimited image quota). A Stripe image_quota feature can replace the quota. The catalog has no per-image price. | Replace the attachment when the card fields change. Pass updated_at as iat. The API docs say a present iat keeps the hosted cache for 24 hours from that timestamp. Omit iat and the hosted cache does not expire on its own. | OG Pilot owns the template. You own the job, the attachment, and the public URL. | Yes. Pass template, title, description, and path. Templates are listed on /docs, including blog_post and product. |
API behavior is from /docs and from OgPilotRuby.create_blog_post_image in the og_pilot_ruby gem. json: true returns the parsed JSON. A request error returns a Hash with image_url set to nil and does not raise. The success body in this app includes status, url, width 1200, and height 630. A render that is still running returns HTTP 202 with status processing or failed, url null, and Retry-After 30.
Meta tags in the layout
Put og:title, og:description, og:image, og:url, and twitter:card in the layout head, filled from the record. content_for :head keeps the show template in charge of the values. og:url is the page. og:image is the image. twitter:card is summary_large_image when you want the large card.
A static file can live in public/. root_url makes the image URL absolute, which is what a crawler can fetch. Width and height in the record sample are the API response values, 1200 and 630. Pass the real dimensions of a file you drew yourself. rails_blob_url builds an absolute URL from the current request. In production the host has to be the public host. A disk service on localhost is not a URL a social crawler can fetch. Confirm the live URL in the Open Graph debugger.
<%# public/social-card.jpg â one file for the whole site %> <%= tag.meta property: "og:image", content: URI.join(root_url, "/social-card.jpg").to_s %> <%= tag.meta property: "og:title", content: "Your site" %> <%= tag.meta property: "og:description", content: "One description for every URL" %> <%= tag.meta property: "og:url", content: request.original_url %> <%= tag.meta name: "twitter:card", content: "summary_large_image" %>
# app/helpers/open_graph_helper.rb
module OpenGraphHelper
def open_graph_tags(title:, description:, url:, image: nil, width: nil, height: nil)
tags = []
tags << tag.meta(property: "og:title", content: title)
tags << tag.meta(property: "og:description", content: description)
tags << tag.meta(property: "og:url", content: url)
tags << tag.meta(property: "og:image", content: image) if image.present?
tags << tag.meta(property: "og:image:width", content: width) if width.present?
tags << tag.meta(property: "og:image:height", content: height) if height.present?
tags << tag.meta(name: "twitter:card", content: "summary_large_image")
safe_join(tags)
end
end
# app/views/layouts/application.html.erb, inside head
<%= yield :head %>
# app/views/posts/show.html.erb
<% content_for :head do %>
<%= open_graph_tags(
title: @post.title,
description: @post.excerpt,
url: post_url(@post),
image: (@post.og_image.attached? ? rails_blob_url(@post.og_image) : nil),
width: 1200,
height: 630
) %>
<% end %>
Call the API and attach the file
The job runs when the record is created and when the title or excerpt changes. It does not run on the HTML request. Attaching the file does not change those columns, so the callback does not enqueue itself again. Map author and published_at to your own schema. create_blog_post_image sets template to blog_post. Product, event, and the other helpers are on /docs.
image_type jpeg is the documented claim for the delivered format. The JSON body does not include a content type, so the sample sets image/jpeg to match that claim. Attach only when status is completed and url is present. A 202 means the render is still going. Leave the current attachment and retry the job. The processing response sets Retry-After to 30.
iat is the record's updated_at. The docs say a present iat keeps the hosted cache for 24 hours from that timestamp. Omit it and the hosted cache does not expire on its own. The API key stays in OG_PILOT_API_KEY. The browser never sees it. The gem version below is the one published on /docs.
To draw the card yourself, keep the same model, job, and attach call. Replace the gem call with the bytes from your library or browser. This page does not name that library, and it does not publish how long that render takes or what it costs to run.
# Gemfile â version from /docs
gem "og_pilot_ruby", "~> 0.4.1"
# bundle install
# bin/rails g og_pilot_ruby:install
# Set OG_PILOT_API_KEY and OG_PILOT_DOMAIN on the server.
# app/models/post.rb
class Post < ApplicationRecord
has_one_attached :og_image
after_commit :refresh_og_image_later, if: :og_card_fields_changed?
private
def og_card_fields_changed?
previously_new_record? || saved_change_to_title? || saved_change_to_excerpt?
end
def refresh_og_image_later
RefreshPostOgImageJob.perform_later(self)
end
end
# app/services/posts/og_image.rb
metadata = OgPilotRuby.create_blog_post_image(
title: post.title,
description: post.excerpt.to_s,
author_name: post.author&.name,
publish_date: post.published_at&.to_date&.iso8601,
path: "/posts/#{post.to_param}",
image_type: "jpeg",
iat: post.updated_at.to_i,
json: true
)
# Attach only when metadata["status"] == "completed" and metadata["url"] is present.
# Download that https URL and post.og_image.attach the bytes as image/jpeg.
The same card from MCP
An agent that already has the public page URL can call generate_og_image on the OG Pilot MCP server. The tool takes url and an optional style. It returns image_url. It does not take a Rails model, and it does not write Active Storage. Use the job above when the file has to be attached. Use MCP when the agent should produce a card for a URL that is already public.
Auth is OAuth 2.1 with PKCE. The config file has no API key. generate_og_image needs a signed-in account with an active plan and a credit. A trial can call the API after the domain is verified. A trial cannot call generate_og_image.
{
"mcpServers": {
"og-pilot": {
"type": "streamable-http",
"url": "https://ogpilot.com/mcp"
}
}
}
generate_og_image
{ "url": "https://example.com/posts/your-post" }
How to add a Rails OG image
- Pick a static file, your own renderer stored in Active Storage, or the OG Pilot API with the file cached on the record.
- Print og:title, og:description, og:image, og:url, and twitter:card from the layout.
- When the card is generated, attach it in a job and replace it when the record's card fields change.
- Create an API key and call OgPilotRuby from that job, or install MCP if an agent should generate a card for the public URL.
- Check the public URL in the Open Graph debugger.
Which Rails approach should I use for og:image? A static file when every URL can share one card. Your own renderer plus Active Storage when you want to own the layout. The OG Pilot API, with the file attached, when you want a per-record card and you do not want to operate an image library or a headless browser. Does og_pilot_ruby write Active Storage? No. You download the completed url and attach it. Can MCP attach a Rails OG image? No. generate_og_image returns image_url for a public page. Will this page publish latency or price benchmarks? No. This page does not publish a render time or an API latency. The plan catalog has no per-image price.