ScreenshotNeo

BlogHow-to

How to Generate Open Graph Images in Ruby

Render a consistent 1200×630 Open Graph image in Ruby with Grover, Ferrum, or a hosted API, then publish it through og:image.

By the ScreenshotNeo team29 September 202610 min read

How to Generate Open Graph Images in Ruby

To generate an Open Graph image in Ruby, build a fixed-size HTML/CSS card from your page data, render that card in a browser, save the resulting PNG, and place its public URL in the page’s og:image metadata. In Rails, the most maintainable pattern is a dedicated view template for the card plus a background job that renders and stores one image per post.

This guide covers three practical implementations: Grover for a Ruby wrapper around Puppeteer and Chromium, Ferrum for direct Chrome DevTools control, and a hosted HTML-to-image API. It also explains dimensions, fonts, asset loading, caching, deployment, failure handling, and how to publish a stable URL that social crawlers can fetch.

1. What an Open Graph image is

Open Graph metadata lets a page describe itself to social networks and other link preview consumers. The image is declared in the document head:

<meta property="og:title" content="How to Generate Open Graph Images in Ruby">
<meta property="og:description" content="Render a consistent social image from Ruby.">
<meta property="og:type" content="article">
<meta property="og:url" content="https://example.com/posts/ruby-og-images">
<meta property="og:image" content="https://cdn.example.com/og/ruby-og-images.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">

The metadata points to an image URL; it does not create the image. Your application must render the card first and make the resulting file publicly reachable. The Open Graph protocol documentation is at ogp.me.

2. Choose a rendering approach

Approach Best when Operational responsibility
Grover You want a high-level Ruby API that accepts a URL or HTML and returns PNG/JPEG/PDF bytes. Node, Puppeteer, Chromium, fonts, and browser process limits.
Ferrum You need direct control over Chrome navigation, waits, JavaScript, and screenshots. Chrome or Chromium must be installed and reachable through PATH or BROWSER_PATH.
Hosted HTML-to-image API You prefer not to package and operate a browser in each Ruby deployment. API credentials, network calls, vendor retention, privacy, latency, and service continuity.

All three approaches use browser layout, so normal HTML and CSS rules apply. There is no source-grounded reason to claim one is universally fastest or most compatible. Select based on control, deployment constraints, and where you want the rendering runtime to live.

3. Build a deterministic card template

Keep the Open Graph card separate from your normal article view. Give it an explicit width and height, a small set of fields, and predictable fallback values. Escape user-provided values using your framework’s normal view escaping.

The Open Graph pipeline: page data becomes a fixed HTML card, then a rendered image and stable public URL.
The Open Graph pipeline: page data becomes a fixed HTML card, then a rendered image and stable public URL.
<!-- app/views/og_cards/post.html.erb -->
<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <style>
      * { box-sizing: border-box; }
      html, body { margin: 0; width: 1200px; height: 630px; }
      body {
        display: flex;
        align-items: flex-end;
        padding: 64px;
        color: #fff;
        background: linear-gradient(135deg, #171717, #7f1d1d);
        font-family: Arial, Helvetica, sans-serif;
      }
      .card { width: 100%; }
      .eyebrow { color: #fecaca; font-size: 24px; margin-bottom: 20px; }
      h1 { max-width: 1030px; margin: 0 0 24px; font-size: 64px; line-height: 1.05; }
      .byline { font-size: 26px; color: #f5f5f5; }
    </style>
  </head>
  <body>
    <main class="card">
      <div class="eyebrow"><%= category %></div>
      <h1><%= title %></h1>
      <div class="byline">By <%= author %></div>
    </main>
  </body>
</html>

Use absolute URLs for images, fonts, and stylesheets when the renderer runs outside your web request. Grover’s documentation specifically warns that relative assets need a suitable display_url or absolute paths. In production, make the card self-contained where possible: inline critical CSS, use a known font stack, and avoid dependencies that require an authenticated session.

4. Render with Grover in Ruby or Rails

Grover converts HTML or a URL into PDF, PNG, or JPEG through Puppeteer and Chromium. Add it to your Gemfile, install the JavaScript runtime dependencies described by the selected Grover release, and ensure the deployment image contains the required browser.

# Gemfile
gem "grover"
# Rails service object
class GenerateOgImage
  def self.call(post)
    html = ApplicationController.render(
      template: "og_cards/post",
      assigns: {
        title: post.title,
        author: post.author.name,
        category: post.category
      }
    )

    png = Grover.new(
      html,
      display_url: Rails.application.routes.url_helpers.root_url,
      viewport: { width: 1200, height: 630, device_scale_factor: 1 }
    ).to_png

    key = "og/#{post.to_param}.png"
    ActiveStorage::Blob.create_and_upload!(
      io: StringIO.new(png),
      filename: "#{post.to_param}.png",
      content_type: "image/png",
      metadata: { width: 1200, height: 630 }
    )
  end
end

The exact browser installation command depends on your Node and Puppeteer versions, so pin compatible versions in your application and verify the selected Grover release’s current Ruby requirements. The RubyGems registry lists Grover releases; requirements can change between versions.

Grover details to decide up front

  • HTML versus URL: Pass rendered HTML when the card is private or generated from unsaved data. Pass a URL when the browser must load external assets from your application.
  • Viewport: Set 1200 by 630 for a conventional landscape card. Keep the same dimensions in CSS and renderer configuration.
  • Device scale factor: Use 1 for a 1200×630 output. A higher scale factor creates a larger pixel image and increases memory and storage use.
  • Fonts: Install the fonts in the worker image or use a reliable web font URL. Wait until the font is available before capture.
  • Output: PNG preserves sharp text and transparency; JPEG is smaller for photographic backgrounds. Use the format supported by your delivery and preview requirements.

5. Render directly with Ferrum

Ferrum is a high-level Ruby API for Chrome that communicates through the Chrome DevTools Protocol without Selenium, WebDriver, or ChromeDriver. It runs headless by default. Install Chrome or Chromium, place it on PATH, or configure BROWSER_PATH.

require "ferrum"

browser = Ferrum::Browser.new(
  browser_path: ENV.fetch("BROWSER_PATH", nil),
  window_size: [1200, 630]
)

begin
  browser.go_to("https://example.com/og-cards/post-42")
  browser.at_css("body")
  browser.screenshot(path: "tmp/og/post-42.png", full: false)
ensure
  browser.quit
end

For inline HTML, serve the rendered template from a local route or data URL, then navigate to it. A local route is usually easier when the card references images or fonts. Always call quit in an ensure block so a failed render does not leave Chrome processes behind.

Waiting for complete visual state

A screenshot can be taken before web fonts, images, or JavaScript have finished. Wait for a distinctive selector, a known application state, or a short delay only when necessary. If your page makes background requests, wait for the page’s own “ready” marker rather than guessing a large sleep. Disable animations in the card CSS:

*, *::before, *::after {
  animation: none !important;
  transition: none !important;
}

6. Use a hosted HTML-to-image API

A hosted renderer can accept HTML and return an image URL, avoiding a browser binary in your Ruby workers. The documented Ruby client for html2img describes Open Graph and per-post social images, requires Ruby 3.1 or newer and an API key, and demonstrates a 1200×630 render. Its documentation describes free-tier renders as hosted for seven days and paid-plan renders as permanent; confirm current terms before relying on retention.

# Illustrative Ruby shape; use the current client API from its documentation
client = Html2img::Client.new(api_key: ENV.fetch("HTML2IMG_API_KEY"))
result = client.render(
  html: rendered_card_html,
  width: 1200,
  height: 630
)
public_url = result.url

Keep the API key server-side. Decide whether the provider may retain your HTML, whether generated URLs are public, how long you need the image, and what your retry policy should be. A hosted service reduces browser maintenance but adds an external dependency and a network hop.

7. Store and publish a stable image URL

  1. Generate the card when a post is created or its title, author, or branding changes.
  2. Write the bytes to object storage or another durable public asset store.
  3. Use a stable URL such as https://cdn.example.com/og/posts/42-v3.png.
  4. Set og:image, og:image:width, and og:image:height in the page head.
  5. Serve the image without authentication, redirects that require cookies, or expiring query parameters.

Generating on every page request increases latency and can create duplicate browser work. A background job plus a cache or object store lets normal page requests read a finished URL. Include a content version in the key when the design changes.

8. Or skip the browser setup

ScreenshotNeo provides a website screenshot API and MCP server. One GET request returns a PNG, JPEG, WebP, or PDF, so your Ruby job can send a URL to a hosted browser instead of installing Chromium. See the ScreenshotNeo API documentation for the complete option list.

A capture service can remove obstructing consent banners and overlays before producing the social image.
A capture service can remove obstructing consent banners and overlays before producing the social image.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com/posts/42 -o shot.webp
require "net/http"
require "uri"

uri = URI("https://api.screenshotneo.com/v1/shot")
uri.query = URI.encode_www_form(access_key: ENV.fetch("SCREENSHOTNEO_API_KEY"), url: "https://example.com/posts/42")
response = Net::HTTP.get_response(uri)
File.binwrite("shot.webp", response.body)
raise "Screenshot failed: #{response.code}" unless response.is_a?(Net::HTTPSuccess)
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://example.com/posts/42"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.com/posts/42' }); const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

Cookie banners, newsletter popups, and chat widgets are removed before the shot, and each cleanup step can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed; response headers identify the page verdict and whether the shot was billed. Options include full-page or CSS-selector capture, dark mode, device presets, custom viewport and retina scale, custom CSS and JavaScript, clicks, waits, blocked requests, headers, cookies, user agents, authorization, timezone, geolocation, transparent backgrounds, resizing, TTL caching, signed image links, asynchronous jobs with signed webhooks, bulk capture of up to 100 URLs per call, a usage API, and an OpenAPI specification. An MCP server exposes take_screenshot, get_page_info, and capture_pdf to Claude, Cursor, and other MCP clients.

There is a free allowance of 1,000 shots per month with no card. Paid plans start at $5 for 3,000 shots; yearly billing gives two months free. Create a free ScreenshotNeo account.

9. Troubleshooting checklist

Symptom Likely cause Fix
Blank or transparent image The page or card has not rendered, or the background is transparent. Navigate to a dedicated card route, wait for a ready selector, and set an explicit background color.
Missing logo or font Relative asset URL, blocked request, or missing deployment font. Use absolute URLs, inline critical assets, install the font, and inspect browser logs.
Text is clipped Title length exceeds the fixed layout. Add a line clamp, reduce font size for long titles, or define a fallback title.
Ferrum cannot start Chrome Chrome is absent or not discoverable. Install Chrome/Chromium and set BROWSER_PATH; check executable permissions.
Grover fails in deployment Node, Puppeteer, Chromium, or compatible versions are missing. Pin dependencies and build the browser into the worker image.
Social preview shows an old image A crawler cached the previous URL. Change the versioned asset URL when content changes and validate the final HTML.
Request times out Slow third-party assets or an application route waiting on a session. Remove nonessential requests, use a dedicated public card route, and set a bounded retry policy.

10. Performance, reliability, and cost

  • Reuse browser processes carefully: Starting Chrome for every card is expensive, but sharing one browser across untrusted jobs can leak state. Use isolated contexts and clear cookies when reusing a process.
  • Cache by content: Hash the title, author, design version, and relevant image URLs. Skip rendering when the hash already exists.
  • Limit concurrency: Browser captures consume memory. Queue jobs and set a worker limit based on observed container capacity.
  • Retry selectively: Retry transient network failures with backoff. Do not endlessly retry invalid HTML, missing assets, authentication failures, or bot challenges.
  • Track outcomes: Record render duration, output size, browser errors, and storage URL. For ScreenshotNeo, inspect X-Page-Verdict and X-Billed to distinguish clean captures from non-billable failures and cache hits.
  • Control storage: PNGs are larger but preserve text. WebP can reduce transfer size. Set lifecycle rules for superseded versions.

There is no universal throughput number for these implementations. Measure your own card complexity, font loading, worker size, and storage path before selecting concurrency or a service plan.

11. Validation before publishing

  1. Open the generated image directly in a browser and inspect small text at 1200×630.
  2. Fetch the article HTML from outside your private network and confirm the og:image URL returns the expected content type.
  3. Test long titles, missing authors, non-ASCII characters, and posts without a hero image.
  4. Regenerate after changing the template or fonts, and update the versioned URL.
  5. Check the preview on the platforms you target. Platform limits and crawler behavior can change, so verify them separately from this implementation.

12. FAQ

Should I generate Open Graph images synchronously?

Usually no. A background job avoids delaying the article request and makes browser failures retryable. Synchronous generation can be acceptable for a small internal tool where the first request must produce an image immediately.

Can I use a normal article page as the capture target?

You can, but a dedicated card route is more predictable. Article pages often include navigation, cookie banners, lazy content, analytics, and responsive layouts that do not belong in a social card.

Is 1200×630 mandatory?

No. It is a practical conventional size for a landscape card. Choose a different fixed size only when your distribution requirements call for it, and keep the renderer, CSS, metadata, and tests consistent.

How should I handle user-generated titles?

Escape them through the normal view layer, constrain their length or line count, and test punctuation, emoji, right-to-left scripts, and unusually long words.

When is a hosted renderer the better choice?

Choose one when maintaining Chrome, Puppeteer, fonts, and worker images is more operational work than your team wants. Review credential handling, privacy, retention, latency, and outage behavior before committing.