Rails API + Open Graph: Add OG Tags to Your API-Mode App
Rails API mode doesn't render HTML — here's how to add og:title, og:image, and Twitter card tags using a separate SSR frontend or a hybrid approach with ActionView.
The core problem with Rails API mode
When you generate a Rails app with --api, it strips out ActionView and middleware needed to render HTML. Social crawlers (Twitter, LinkedIn, Slack, Discord) scrape raw HTML for <meta property="og:..."> tags. If your Rails app only returns JSON, those crawlers get nothing.
You have three options:
- Add OG tags in your SSR frontend (Next.js, Nuxt, etc.)
- Add a minimal HTML rendering layer back to Rails API mode
- Build a server-side OG proxy endpoint in Rails
Option 1: OG tags in Next.js frontend (recommended)
If you have a Next.js or Nuxt.js frontend consuming your Rails API, add OG tags in the frontend's generateMetadata:
// app/posts/[slug]/page.tsx (Next.js App Router)
export async function generateMetadata({ params }: { params: { slug: string } }) {
const post = await fetch(`https://api.myapp.com/posts/${params.slug}`).then(r => r.json());
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [{ url: post.og_image_url || `https://myapp.com/og/${params.slug}.png`, width: 1200, height: 630 }],
type: 'article',
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.excerpt,
images: [post.og_image_url || `https://myapp.com/og/${params.slug}.png`],
},
alternates: { canonical: `https://myapp.com/posts/${params.slug}` },
};
}Your Rails API should return og_image_url in the JSON response so the frontend doesn't have to construct it:
# app/controllers/api/v1/posts_controller.rb
def show
post = Post.find_by!(slug: params[:slug])
render json: {
title: post.title,
excerpt: post.excerpt,
body: post.body,
og_image_url: rails_blob_url(post.og_image, only_path: false),
published_at: post.published_at.iso8601,
}
endOption 2: Re-enable HTML rendering in Rails API mode
You can add HTML rendering back to a specific controller without leaving API mode:
# config/application.rb # Add ActionView back for the OG proxy config.api_only = true
# app/controllers/og_controller.rb
class OgController < ActionController::Base # Not API::Base
def show
@post = Post.find_by!(slug: params[:slug])
render html: og_html(@post).html_safe, layout: false
end
private
def og_html(post)
<<~HTML
<!DOCTYPE html>
<html>
<head>
<meta property="og:title" content="#{CGI.escapeHTML(post.title)}" />
<meta property="og:description" content="#{CGI.escapeHTML(post.excerpt)}" />
<meta property="og:image" content="https://myapp.com/og/#{post.slug}.png" />
<meta property="og:url" content="https://myapp.com/posts/#{post.slug}" />
<meta property="og:type" content="article" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="https://myapp.com/og/#{post.slug}.png" />
</head>
<body></body>
</html>
HTML
end
end# config/routes.rb get '/og/:slug', to: 'og#show'
Dynamic OG image endpoint in Rails
Serve dynamically generated OG images from Rails using the gruff or mini_magick gem:
# app/controllers/api/og_images_controller.rb
class Api::OgImagesController < ApplicationController
def show
post = Post.find_by!(slug: params[:slug])
# Cache the generated image using Rails cache
png = Rails.cache.fetch("og-image/#{post.slug}/#{post.updated_at.to_i}", expires_in: 1.day) do
generate_og_image(post.title, post.excerpt)
end
send_data png,
type: 'image/png',
disposition: 'inline',
filename: "#{post.slug}-og.png"
end
private
def generate_og_image(title, description)
# Using ImageMagick via MiniMagick
image = MiniMagick::Image.create('.png') do |f|
f.size '1200x630'
f.background '#0f0f0f'
f.fill 'white'
f.font 'Helvetica-Bold'
f.pointsize 56
f.gravity 'NorthWest'
f.annotate '+60+200', title[0..50]
f.pointsize 28
f.fill '#aaaaaa'
f.annotate '+60+300', description[0..100]
end
image.to_blob
end
endCommon Rails API OG mistakes
- Assuming JSON gets scraped — social crawlers only read HTML. JSON responses produce no OG preview.
- Missing CORS headers on OG image endpoint — OG image URLs are fetched directly by scrapers; they don't need CORS headers, but they do need to be publicly accessible (no auth).
- Using Active Storage private blobs — OG images must be publicly accessible URLs. Use Active Storage's public service or sign URLs with long expiry.
Verify your Rails API OG tags
After deploying, paste your URL into OGFixer to see exactly how your app's link previews appear on Twitter, LinkedIn, Slack, and Discord — and catch missing or broken tags before sharing.