OpenGraph Image Generation Specification

1. Problem Statement

When a page is shared on a social platform (Twitter/X, Facebook, LinkedIn, Slack, iMessage, etc.), the platform fetches the og:image meta tag to display a visual preview. This feature generates dynamic, branded preview images for any page backed by a record — without requiring manual design work per page.

The feature owns:

  • A generic HTML template that renders a full-page visual card given a record and a set of locals.
  • A controller endpoint (or endpoints) that serve that template as a standalone HTML document.
  • A model-side interface that produces the final image URL (via an external screenshot service).
  • Integration with the host layout’s meta-tag system to surface the image URL.

The feature does NOT own:

  • The meta-tag rendering infrastructure itself (that lives in the host layout).
  • The external screenshot service (e.g. a Cloudflare Worker or Browserless instance) that renders HTML to PNG.
  • The record models beyond the narrow interface described in §4.
  • SEO title/description generation (adjacent but separate concern).

2. Goals and Non-Goals

2.1 Goals

  • Any record type that represents a user-facing page MAY opt in to automatic OG image generation by implementing the interface in §4.
  • OG images are generated automatically — no manual design per page.
  • The visual card is a self-contained HTML document renderable by a headless browser at a fixed viewport.
  • Adding a new OG-enabled record type requires no new infrastructure — only a model including the interface and (optionally) a route.

2.2 Non-Goals

  • Records that do not opt in use the site-wide fallback OG image; there is no per-record override UI.
  • No A/B testing or variant generation for OG images.
  • No end-user UI for previewing or editing OG images.

3. System Overview

3.1 Main Components

Component Responsibility
OG HTML template Renders a full-page visual card as a standalone HTML document from the target record.
OG controller action(s) Serve the template at a public URL for the screenshot service to crawl.
Model interface (see §4) Expose #opengraph_image and related methods on any record that wants an OG image.
SEO helpers Wire the image URL into the host layout’s og:image and twitter:image meta tags.

3.2 Rendering Pipeline

Social platform requests og:image URL
        │
        ▼
Screenshot service receives request
        │
        ▼
Screenshot service fetches /og/:record_type/:id (absolute URL)
        │
        ▼
Rails renders the OG template as a standalone HTML document (layout: false)
        │
        ▼
Screenshot service rasterizes HTML → returns PNG

3.3 External Dependencies

  • Screenshot service: a public HTTP endpoint that accepts ?url=<absolute-url> and returns a PNG of the rendered page. Implementation-agnostic — Cloudflare Workers, Browserless, shot-scraper-as-a-service, or a self-hosted headless Chromium.
  • (Optional) Web font CDN: the template MAY load custom fonts from a CDN; the screenshot service must be able to reach it.

4. Core Domain Model

4.1 The OpengraphImage Interface

Any record that wants an OG image implements the following interface. In Ruby this is naturally a concern (app/models/concerns/opengraph_image.rb) included by the model; in another language it can be a trait, mixin, or duck-typed contract.

Method Returns Required? Purpose
#opengraph_image String Required Absolute URL of the final rendered PNG (points at the screenshot service).
#opengraph_url String Required Absolute URL of the standalone HTML page the screenshot service will fetch.
#opengraph_locals Hash Optional Locals handed to the template. Defaults to { record: self }.
#opengraph_updated_at Time Optional Time shown in the “Updated:” badge. Defaults to #updated_at.

4.2 Data Used by the Template

The template is given one or more locals (see §4.1). It consumes only what its specific visual design needs — typically a display name, an updated-at timestamp, and any record-specific body copy. The set of fields is not part of the generic contract; it is part of the template contract for a given visual design.

4.3 Image URL Resolution

#opengraph_image constructs the screenshot-service URL by escaping the record’s #opengraph_url and appending it as a query parameter:

{SCREENSHOT_SERVICE_BASE}/render?url={CGI.escape(record.opengraph_url)}

#opengraph_url MUST be an absolute URL using the canonical public host + https scheme — not the current request’s host — because the screenshot service is an external crawler and request-scoped host information is unreliable.


5. Feature Contract

5.1 Entrypoints and Interfaces

HTTP Endpoints (Routes)

Every OG-enabled record type exposes a public endpoint that returns a standalone HTML document. The recommended convention is the /og/ namespace (see §5.1.1). All such endpoints:

  • Render the OG template with layout: false (a complete HTML document, not a page fragment).
  • Return HTML (not an image) — the screenshot service converts it.
  • Support whatever identifier scheme the record uses (integer PK, UUID, FriendlyId slug, etc.).
  • Are public and require no authentication (see §5.4).

5.1.1 Route Convention (/og/ namespace)

All OG endpoints SHOULD live under a single /og/ namespace:

# config/routes.rb
scope "/og", as: :og do
  # Generic dispatch (preferred):
  get ":record_type/:id", to: "opengraph#show", as: :record

  # Or per-record-type routes if generic dispatch is undesirable:
  # get "posts/:id",          to: "opengraph#post",     as: :post
  # get "categories/:id",     to: "opengraph#category", as: :category
  # get "users/:username",    to: "opengraph#user",     as: :user
end

Why a dedicated namespace:

  • Visual clarity: /og/... tells a reader these are screenshot targets, not canonical user-facing URLs.
  • Operability: a single prefix is easy to firewall, cache, rewrite at the edge, or exclude from analytics.
  • Scalability: new OG-enabled record types drop in without threading new actions through existing resourceful routes.
  • Security: a single OpengraphController can centralize record-type allow-listing so arbitrary params[:record_type] values cannot load arbitrary model classes (see §5.5).

If the application already has OG routes nested inside resourceful routes (e.g. /things/:id/opengraph), those MAY be kept as aliases during migration — but any rename must update #opengraph_url helpers in the same change, or cached social previews will 404 until crawlers re-fetch.

Model Methods

See §4.1 for the required interface.

5.2 Input and Output Semantics

OG controller endpoints:

  • Input: a record identifier (and record-type discriminator, if using generic dispatch).
  • Output: a complete HTML document — DOCTYPE, head with stylesheets/fonts, body with the visual card.
  • The HTML is designed to be rendered at the template’s target viewport (§9) and screenshotted as-is.

5.3 Core Behavioral Rules

  1. Every record that implements the OpengraphImage interface MUST have a resolvable og:image URL via #opengraph_image.
  2. Records that do not implement the interface fall back to the site-wide default OG image.
  3. #opengraph_url always uses the canonical public host and https, regardless of the current request context.
  4. The template renders from whatever locals the record’s #opengraph_locals provides; the template’s visual design determines which fields it reads.

5.4 Permissions and Access Control

  • OG template endpoints are public — no authentication, no CSRF, no session.
  • They MAY rate-limit by IP if abuse becomes a concern, but the default expectation is unauthenticated crawler access.

5.5 Failure Handling and Error Surface

  • Record not found: return a standard 404.
  • Disallowed record_type (generic dispatch only): return 404 or 400 — never attempt to constantize untrusted input. The controller MUST maintain an explicit allow-list of OG-enabled model classes.
  • Screenshot service unavailable: social platforms will show no preview image or a broken image. There is no fallback at the og:image URL level; the page’s meta tags MAY fall back to the site-wide default if the application detects the service is down, but this is out of scope for the core spec.

6. Configuration and Runtime Assumptions

6.1 Configuration Inputs

Config Source Purpose
Screenshot service base URL ENV / credentials Prefix used to build #opengraph_image URLs.
Canonical public host ENV / credentials / config Host used in #opengraph_url (must match what the screenshot service can reach).
Protocol Config (default https) Used in all generated OG URLs.

The reference implementation currently hard-codes the service URL and public host; making them configuration-driven is a straightforward extraction and should be done before re-using the feature in a second application.

6.2 Runtime Assumptions

  • The screenshot service is deployed and reachable from the public internet.
  • The application serves the OG template endpoints over HTTPS at the canonical public host.
  • The application’s CSS pipeline (Tailwind, PostCSS, Propshaft, Sprockets, etc.) is available at the rendered URL — the template depends on compiled stylesheets loading.
  • Any static assets the template references (background images, logos) exist at their expected public paths.
  • Any external font CDN the template uses is reachable from the screenshot service’s rendering environment.

7. Observability and Operations

  • No dedicated logging, metrics, or analytics for OG image generation are specified. Implementations MAY add them.
  • No caching headers are required on OG template endpoints. The screenshot service typically re-fetches on every crawl, and per-crawl rendering is the default assumption.

8. Performance Characteristics

  • Dynamic rendering: latency = screenshot-service cold-start + HTML fetch time + rasterization time. This happens on every social-platform crawl.
  • No caching headers are assumed on OG template endpoints. Implementations MAY add CDN caching if crawl volume becomes a concern.

9. Visual Template Specification

9.1 Normative Template Contract

The generic contract is minimal:

  • The template produces a complete, standalone HTML document (DOCTYPE → <html><head><body>).
  • It is designed to render at a fixed viewport (the first reference implementation uses 1400×748; any viewport is permitted as long as the screenshot service is configured to match).
  • It consumes locals per §4.1 — typically a display name, an updated-at timestamp, and any record-specific body copy.
  • It loads everything it needs inline or via absolute URLs: stylesheets, fonts, images. Nothing may depend on the host page’s runtime JS.
  • Hidden scrollbars, no animation, no interactivity — the rendered output is rasterized once.

9.2 Example Implementation (Non-Normative)

The reference application’s template is a single-record landing-page card with the following structure:

Layout: full-viewport height, dark branded background, centered content with generous padding, decorative texture in one corner.

Content (top to bottom):

  1. Headline — two-line: a fixed prefix (“The Ultimate Guide to”) and a record-specific display name styled for emphasis.
  2. Bullet list — three lines: one record-specific, two generic value propositions.
  3. CTA button — pill-shaped, high-contrast.
  4. Updated-date badge — pill-shaped, showing "Updated: {Month DD, YYYY}" from the record’s #opengraph_updated_at.

Typography: custom display font loaded from a web-font CDN.

Target viewport: 1400×748.

None of these specifics are part of the generic contract — they are one application’s visual design and are expected to vary per product.


10. Open Questions and Ambiguities

  1. Screenshot service contract: the service is a black box. Its rendering engine, caching behavior, timeout limits, and supported image formats/dimensions are outside this spec and need to be documented alongside whichever service is chosen.
  2. Per-record cache invalidation: there is no mechanism defined for busting a crawler’s cached OG image after a record is edited. Practical solutions (content-hash query strings, versioned URLs) are an application concern.

11. Example Implementation Evidence (Non-Normative)

The reference application adopts this spec with the following files. These are illustrative — they are not part of the contract and are not required for a compliant implementation.

File What it illustrates
app/views/shared/_opengraph.html.erb One concrete template design; reads record-specific locals; loads Tailwind + Google Fonts + a local background texture.
app/controllers/<record>_controller.rb (action: #opengraph) Pattern for rendering the template with layout: false.
config/routes.rb Current routes are nested inside resourceful routes rather than under /og/ — an intentional divergence from §5.1.1 pending a future refactor.
app/models/<record>.rb (#opengraph_image) Example of constructing the screenshot-service URL with a hard-coded canonical host.
app/helpers/application_helper.rb (set_seo_meta, set_*_seo) Example of wiring #opengraph_image into the host layout’s meta tags.
app/views/layouts/_head.html.erb Example of meta-tag rendering via content_for.
public/opengraph.png Site-wide fallback OG image for pages that do not opt in.

12. Implementation Notes (Non-Normative)

  • In Rails, the OG template is typically a partial (e.g. _opengraph.html.erb) despite being a complete HTML document. That is intentional — it is rendered with layout: false and must stand alone.
  • Any CSS the template uses beyond utility classes (custom backgrounds, ::before pseudo-elements, etc.) should be embedded in the template’s own <style> block rather than relying on application-wide CSS that might be tree-shaken.
  • A single generic OpengraphController#show can dispatch on params[:record_type] via an explicit allow-list (e.g. { "post" => Post, "category" => Category }), keeping routing simple while preventing arbitrary class loading.
  • When migrating from non-namespaced OG routes to the /og/ convention, keep the old routes as aliases until all #opengraph_image callers are updated and all crawler caches can reasonably be expected to have expired.

13. Out of Scope

  • The screenshot service’s implementation and deployment.
  • The host layout’s meta-tag rendering infrastructure (shared across all page types, not OG-specific).
  • SEO title/description generation (separate concern; only the image-URL integration is in scope).
  • Records that intentionally do not opt in to OG generation — they use the site-wide fallback, and that fallback’s design is out of scope.
  • Analytics, click tracking, or any observability on OG image renders.