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
| Route | Returns |
|---|---|
/open-graph/v1/og.png | The rendered card. This is what og:image points at. |
/open-graph/v1/og.svg | The same card as vector, for checking layout. Not for og:image: no major crawler accepts SVG there. |
/open-graph/v1/tags | JSON containing the tag map, a ready to paste HTML block, and the absolute image URL. |
/open-graph/v1/tags.html | The meta tag block on its own, as plain text. |
/open-graph/v1/meta | Tags plus the resolved card specification, for build pipelines that want to log what was rendered. |
/open-graph/v1/embed.js | A client script configured entirely by data- attributes on its own tag. |
/open-graph/v1/themes | Available templates, themes, patterns, sizes and colour tokens. |
/open-graph/health | Liveness, 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.
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
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
pattern=diagonal&site=schemacdn.com&subtitle=Governed%20structured%20data%2C%20one%20deploy&template=banner&theme=forest&title=SchemaCDN%202.0
site=jakelabate.com&template=stat&theme=ink&title=Technical%20SEO%20audit%20results
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.
site=cdn.jakelabate.com&subtitle=One%20endpoint%20for%20the%20card%20and%20the%20markup&template=minimal&theme=paper&title=Open%20Graph%2C%20on%20demandLong 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&site=jakelabate.com&subtitle=Why+the+same+markup+serves+both+engines&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&site=jakelabate.com&subtitle=Why+the+same+markup+serves+both+engines&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&site=jakelabate.com&subtitle=Why+the+same+markup+serves+both+engines&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
| Parameter | Notes |
|---|---|
title | Headline. Falls back to site when absent. |
subtitle | Also accepted as description. Feeds og:description. |
site, author | Footer line. site also feeds og:site_name. |
date, meta | Byline detail for the article template, attribution for the quote template. |
stat | Repeatable, Value|Label. Three are rendered. |
logo, logoWidth | An 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. |
template | editorial, article, split, banner, quote, stat, minimal, code. |
theme | indigo, ink, violet, sunset, forest, mono, cream, paper, slate. |
pattern | grid, dots, diagonal, glow, off. |
bg, bgAlt, fg, muted, accent, rule | Hex overrides applied on top of the chosen theme. |
size, w, h, scale | Named sizes, explicit dimensions, or a 2x render. |
url, type, locale, card, twitter, alt | Markup 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/.