after_commit hook, Active Job, URL saved on the record.
Rails has no equivalent of the edge image libraries the JavaScript world uses, so the usual answer is running Chrome next to your app through Grover or Ferrum — and babysitting it. A job that makes one HTTP call keeps Chrome off your servers entirely and retries on its own when the network blips.
app/jobs/generate_og_image_job.rb
require "net/http"
class GenerateOgImageJob < ApplicationJob
queue_as :default
retry_on Net::OpenTimeout, Net::ReadTimeout, wait: :polynomially_longer, attempts: 3
ENDPOINT = URI("https://imageapi.dev/v1/images?wait=true")
def perform(post)
body = {
template: "blog-og",
modifications: {
title: post.title,
author: post.author.name,
date: post.published_at.to_date.iso8601,
site: "yourblog.com"
}
}
response = Net::HTTP.start(ENDPOINT.host, ENDPOINT.port, use_ssl: true, read_timeout: 20) do |http|
request = Net::HTTP::Post.new(ENDPOINT)
request["Authorization"] = "Bearer #{Rails.application.credentials.image_api_key}"
request["Content-Type"] = "application/json"
request.body = body.to_json
http.request(request)
end
response.value # raises on anything that is not 2xx
post.update!(og_image_url: JSON.parse(response.body).fetch("url"))
end
end
# app/models/post.rb
# after_commit -> { GenerateOgImageJob.perform_later(self) },
# on: [:create, :update], if: -> { saved_change_to_title? && published? }
Use `after_commit`, not `after_save`. With `after_save` the job can be picked up before the transaction commits, and the worker loads a post whose new title is not in the database yet — so the image shows the old title.
With ?wait=true the finished image arrives on the same connection — usually in about a second.
{
"id": "img_01m08s3ez1zcv28h335g9923bw",
"status": "completed",
"url": "https://cdn.imageapi.dev/renders/img_01m08s3ez1zcv28h335g9923bw.png",
"duration_ms": 912
}
A hundred images a month free, then $9 for two thousand. The same post rendered twice returns the existing URL and uses no quota — so rebuilding your site costs nothing.