Open Graph

An edge API that renders Open Graph card images and returns the meta tags that point at them. No headless browser, no image host, no build step.

What it does

Most Open Graph implementations fail in the same few places: a relative og:image, missing dimensions, a tag block that disagrees with the image it references, or markup injected client-side that no social crawler ever executes. This service returns both halves from one origin so they cannot drift apart.

Layout runs through Satori, which turns an element tree into SVG. Rasterisation runs through resvg. Both are WebAssembly inside the Worker isolate, so a render is a single request with no browser to launch and no queue to wait in.

Fonts are instanced from their variable originals, subset to latin, and bundled as bytes, roughly 110 KB for all three. No render waits on a font host.

Endpoints

RouteReturns
/open-graph/v1/og.pngThe rendered card. This is what og:image points at.
/open-graph/v1/og.svgThe same card as vector, for checking layout. Not for og:image: no major crawler accepts SVG there.
/open-graph/v1/tagsJSON containing the tag map, a ready to paste HTML block, and the absolute image URL.
/open-graph/v1/tags.htmlThe meta tag block on its own, as plain text.
/open-graph/v1/metaTags plus the resolved card specification, for build pipelines that want to log what was rendered.
/open-graph/v1/embed.jsA client script configured entirely by data- attributes on its own tag.
/open-graph/v1/themesAvailable templates, themes, patterns, sizes and colour tokens.
/open-graph/healthLiveness, and the resolved public base.

Using it

Point a tag straight at the renderer:

<meta property="og:image"
      content="https://cdn.jakelabate.com/open-graph/v1/og.png?title=Hello&theme=indigo" />

Or have the service write the whole block, which is the safer option because the image URL it emits is absolute and its declared dimensions always match what was rendered:

curl -s "https://cdn.jakelabate.com/open-graph/v1/tags.html?title=Hello&site=example.com&url=https://example.com/"

The social cards on this page are generated by this endpoint.

Rendered examples

Every image below is loaded live from the endpoint. Nothing here is a screenshot, so if the service is down this section is empty, which is the correct behaviour for documentation.

editorial card: Structured data is the substrate both engines feed on
editorial the default. Headline, deck, footer rule.
author=Jake%20Labate&site=jakelabate.com&subtitle=Why%20the%20same%20markup%20serves%20classic%20search%20and%20generative%20answers&template=editorial&theme=indigo&title=Structured%20data%20is%20the%20substrate%20both%20engines%20feed%20on
article card: What Open Graph actually guarantees
article adds a byline row: author, date, read time.
author=Jake%20Labate&date=Aug%2029%2C%202026&meta=6%20min%20read&site=jakelabate.com&subtitle=And%20the%20four%20places%20every%20implementation%20quietly%20breaks&template=article&theme=mono&title=What%20Open%20Graph%20actually%20guarantees
banner card: SchemaCDN 2.0
banner a horizontal lockup. Holds up best at thumbnail size.
pattern=diagonal&site=schemacdn.com&subtitle=Governed%20structured%20data%2C%20one%20deploy&template=banner&theme=forest&title=SchemaCDN%202.0
stat card: Technical SEO audit results
stat three figures maximum. A fourth pushes each under the legibility floor.
site=jakelabate.com&template=stat&theme=ink&title=Technical%20SEO%20audit%20results
quote card: The same markup feeds both engines, so the argument about which one matters is the wrong argument.
quote a pull quote with attribution.
author=Jake%20Labate&meta=SEO%20Consultant&template=quote&theme=sunset&title=The%20same%20markup%20feeds%20both%20engines%2C%20so%20the%20argument%20about%20which%20one%20matters%20is%20the%20wrong%20argument.
minimal card: Open Graph, on demand
minimal headline alone, centred. A light theme with hex overrides.
site=cdn.jakelabate.com&subtitle=One%20endpoint%20for%20the%20card%20and%20the%20markup&template=minimal&theme=paper&title=Open%20Graph%2C%20on%20demand

Long headlines clamp rather than shrink, so a card is never illegible. The stat example above carries no stat parameters, which is why it renders the headline and footer alone: an absent input produces a simpler card rather than an error.

The tags it returns

The same parameters sent to /v1/tags.html return the markup that points at the card:

curl -s "https://cdn.jakelabate.com/open-graph/v1/tags.html?title=Structured+data+is+the+substrate&subtitle=Why+the+same+markup+serves+both+engines&site=jakelabate.com&author=Jake+Labate&url=https://jakelabate.com/"
<meta property="og:type" content="website" />
<meta property="og:title" content="Structured data is the substrate" />
<meta property="og:description" content="Why the same markup serves both engines" />
<meta property="og:url" content="https://jakelabate.com/" />
<meta property="og:site_name" content="jakelabate.com" />
<meta property="og:locale" content="en_US" />
<meta property="og:image" content="https://cdn.jakelabate.com/open-graph/v1/og.png?author=Jake+Labate&amp;site=jakelabate.com&amp;subtitle=Why+the+same+markup+serves+both+engines&amp;title=Structured+data+is+the+substrate" />
<meta property="og:image:secure_url" content="https://cdn.jakelabate.com/open-graph/v1/og.png?author=Jake+Labate&amp;site=jakelabate.com&amp;subtitle=Why+the+same+markup+serves+both+engines&amp;title=Structured+data+is+the+substrate" />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="Structured data is the substrate" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Structured data is the substrate" />
<meta name="twitter:description" content="Why the same markup serves both engines" />
<meta name="twitter:image" content="https://cdn.jakelabate.com/open-graph/v1/og.png?author=Jake+Labate&amp;site=jakelabate.com&amp;subtitle=Why+the+same+markup+serves+both+engines&amp;title=Structured+data+is+the+substrate" />
<meta name="twitter:image:alt" content="Structured data is the substrate" />

Three details worth noticing. The image URL is absolute, because a relative og:image is the single most common reason a preview comes back blank. Its parameters are sorted alphabetically rather than in the order they were sent, which is the cache key being canonicalised so two callers who wrote the same card differently still share one render. And the declared width and height always match what was rendered, including at scale=2.

Parameters

ParameterNotes
titleHeadline. Falls back to site when absent.
subtitleAlso accepted as description. Feeds og:description.
site, authorFooter line. site also feeds og:site_name.
date, metaByline detail for the article template, attribution for the quote template.
statRepeatable, Value|Label. Three are rendered.
logo, logoWidthAn https URL to a PNG, JPEG, GIF or SVG. Dimensions are read from the file header so the aspect ratio is exact rather than guessed.
templateeditorial, article, split, banner, quote, stat, minimal, code.
themeindigo, ink, violet, sunset, forest, mono, cream, paper, slate.
patterngrid, dots, diagonal, glow, off.
bg, bgAlt, fg, muted, accent, ruleHex overrides applied on top of the chosen theme.
size, w, h, scaleNamed sizes, explicit dimensions, or a 2x render.
url, type, locale, card, twitter, altMarkup only. These never fragment the image cache.

Every value is clamped and stripped. Unknown themes and templates fall back rather than erroring, invalid hex is ignored, control characters are collapsed, and attribute values are escaped on the way into HTML.

The design constraint

The size that matters is not the 1200 by 630 canvas. It is the roughly 300 point width a link preview occupies in a message thread, which is close to a quarter scale. A 30 pixel subtitle arrives as 8 point type and nobody reads it.

Everything measurable therefore derives from that constraint rather than from what looks balanced at full resolution.

  • Type is a modular scale of seven steps, 44 55 69 86 107 134 168, a 1.25 ratio applied to a base of 44. The base is the legibility floor, not a chosen body size: 44 pixels reads as 11.7 point where the card is actually seen. The smallest step on the scale is therefore the smallest readable size by construction.
  • Long headlines step down the scale rather than being multiplied by a factor, so every rendered size is still a step. A headline paired with a deck drops one further step, because two competing display sizes read as an argument.
  • Text clamps rather than shrinks. A truncated headline someone can read beats a complete one they cannot.
  • Space is an 8 point grid addressed by relationship rather than by number. A gap between two elements must exceed the leading inside them, or they read as one block.
  • There are no label elements. No eyebrow, no chip. Both competed for vertical space with the only thing legible at that size.

These are enforced rather than intended. The test suite fails the build if a rendered size is not a step on the scale, if a space is not a named token, if a gap does not clear its leading, if anything reads below 11 point at preview scale, or if any template overflows its own card.

That last check exists because the first version passed the size floor and still clipped three cards at the bottom edge. It works by rendering each template a second time with the canvas height unset and comparing the natural content height against the card it has to fit inside. Writing it also surfaced a silent framework behaviour: Satori ignores lineClamp on a flex container, so no clamping was happening at all.

Caching and limits

The image cache key is the sorted set of visual parameters only. Parameter order never splits the cache, and markup-only parameters never cause a re-render, so a site with hundreds of pages sharing a template hits one cached render rather than hundreds. Responses carry max-age=31536000, immutable, and x-og-cache reports HIT or MISS.

Logo fetching is the one place the service reaches a URL someone else supplied, so it is the one place with real guards: https only, a 512 KB cap, a 3 second timeout, an image content type allowlist, private and link-local hosts blocked, and an optional host allowlist. A logo that fails any check is dropped and the card still renders. x-og-logo reports loaded, failed or none.

Setting a signing key makes /v1/og.png require an HMAC derived from the canonical query, which /v1/tags mints automatically. Without it the renderer is open compute for anyone who finds the URL.

Source

MIT licensed and self-hostable. The type scale, the templates and the gates that enforce both live in src/.

Open Graph Cards That Survive The Preview

Social cards are read at a quarter of the size they are designed at. If yours are illegible in a message thread, that is a solvable problem.