Generating Custom Open Graph Images
Open Graph images are the images that you see when you share a page on social media. The goal of the Open Graph Protocol is to expose information about web pages so that they can “become a rich object in a social graph”. I don’t know how much that goal is achieved by the protocol, but I like when my posts have nice preview images. It helps them stand out.
There are a ton of different ways to use these images. Some bloggers have a big banner image for each post and use that for the social image. Some blogs just pull the first image in the post as the social sharing image. Others do what I do, render a custom image that contains the logo for my site, the title of the post, and a little description.
You might be wondering if putting text in an image goes against accessibility. The text I put in these images doesn’t contain anything that isn’t available through the other Open Graph attributes, but you can use the og:image:alt
attribute to provide alt text for your Open Graph images to make them properly accessible.
At the time of writing, my site doesn’t use that attribute, but I’ve made a note to fix that.
At the time of writing, here’s how my Open Graph images are generated. Each post references an og:image
URL on a CDN, currently AWS CloudFront. The URL for this post looks something like this: https://d75lo4uzaao03.cloudfront.net/generating-custom-open-graph-images.png?t=1754776938
. The t
parameter contains the timestamp that the post was last updated, ensuring the URL changes when the post changes.
The CDN is configured to hit a very simple Rack app that I wrote that looks at the slug in the URL, spins up Chrome, hits a special URL on this site, screenshots it, and returns the screenshot. The CDN then caches the value.
Here’s the meat of the Rack app. Nearly all the complexity here is to do with managing Chrome, and I don’t even think it’s all necessary. I had a simpler version of this app and lost it, so this version was written by Claude Sonnet 4, which added a bunch of garbage when it was trying to diagnose why things weren’t working in prod.
require 'rack'
require 'ferrum'
require 'tempfile'
class ScreenshotApp
def call(env)
request = Rack::Request.new(env)
path = request.path_info
# Match paths like /some-slug.png
if path =~ /^\/(.+)\.png$/
slug = $1
begin
png_data = generate_screenshot(slug)
[200, {
'Content-Type' => 'image/png',
'Content-Length' => png_data.bytesize.to_s,
'Cache-Control' => 'public, max-age=3600'
}, [png_data]]
rescue => e
[500, {'Content-Type' => 'text/plain'}, ["Error generating screenshot: #{e.message}"]]
end
else
[404, {'Content-Type' => 'text/plain'}, ['Not Found']]
end
end
private
def generate_screenshot(slug)
url = "https://jardo.dev/og-previews/#{slug}"
puts "Generating screenshot for: #{url}"
browser_options = {
'no-sandbox' => nil,
'disable-gpu' => nil,
'disable-dev-shm-usage' => nil,
'disable-background-timer-throttling' => nil,
'disable-backgrounding-occluded-windows' => nil,
'disable-renderer-backgrounding' => nil,
'disable-features' => 'TranslateUI,VizDisplayCompositor',
'disable-ipc-flooding-protection' => nil,
'disable-web-security' => nil,
'disable-extensions' => nil,
'hide-scrollbars' => nil,
'mute-audio' => nil,
'no-first-run' => nil,
'disable-default-apps' => nil,
'disable-popup-blocking' => nil,
'disable-translate' => nil,
'disable-background-networking' => nil,
'disable-sync' => nil,
'metrics-recording-only' => nil,
'no-default-browser-check' => nil,
'single-process' => nil,
'memory-pressure-off' => nil
}
# Try different Chrome binary paths
chrome_path = find_chrome_binary
puts "Using Chrome binary: #{chrome_path || 'default'}"
browser = Ferrum::Browser.new(
headless: true,
window_size: [1200, 600],
timeout: 30,
process_timeout: 30,
browser_path: chrome_path,
browser_options: browser_options
)
begin
puts "Navigating to URL..."
browser.go_to(url)
puts "Setting viewport size..."
browser.resize(width: 1200, height: 600)
puts "Page loaded, waiting for content..."
# Wait a bit for the page to fully load
sleep(2)
puts "Taking screenshot..."
screenshot = browser.screenshot(encoding: :binary, full: false)
puts "Screenshot generated successfully"
screenshot
rescue => e
puts "Error during screenshot generation: #{e.message}"
puts "Error backtrace: #{e.backtrace.first(5).join("\n")}"
raise e
ensure
browser&.quit
end
end
def find_chrome_binary
# Check if BROWSER_PATH environment variable is set
return ENV['BROWSER_PATH'] if ENV['BROWSER_PATH'] && File.exist?(ENV['BROWSER_PATH'])
possible_paths = [
'/usr/bin/chromium-browser',
'/usr/bin/chromium',
'/usr/bin/google-chrome-stable',
'/usr/bin/google-chrome',
'/opt/google/chrome/chrome',
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
]
possible_paths.each do |path|
return path if File.exist?(path)
end
# If no specific path found, let Ferrum try to find it
nil
end
end
To recap, here’s the flow:
- The blog post references the CDN-hosted Open Graph image:
https://d75lo4uzaao03.cloudfront.net/generating-custom-open-graph-images.png?t=1754776938
- The CDN receives this request, and attempts to fetch the image from the origin, a Rack app:
https://secret-url-here.com/generating-custom-open-graph-images.png
- The Rack app uses Ferrum to fetch a page on this site which renders a nice looking preview page:
https://jardo.dev/og-previews/generating-custom-open-graph-images
- The Rack app returns a screenshot of that page, then goes back to sleep.
- The CDN caches the returned image until the timestamp changes.
The Rack app costs me basically nothing to run because it spend 99.999% of the time shut down. It starts nearly instantly, serves a single request the first time someone requests an image that isn’t yet on the CDN, then goes back to sleep.
The app also doesn’t need to change, even if I redesign my site. If I were to overhaul everything, I could update my preview image endpoint with the new styles, expire all the images on the CDN, and everything would still continue to work. The only real cost is the CDN, but I don’t get enough traffic that it amounts to a meaningful cost.
Open Graph protocol
The Open Graph protocol enables any web page to become a rich object in a social ...
