Every link shared on X, LinkedIn, Slack, Discord or iMessage shows the page's og:image. A card with the post's own title, author and cover gets noticeably more clicks than the same logo on every page, but nobody designs a thousand cards by hand. Design one card in HTML and CSS, and render it for each page with that page's data.
Render a card from HTML
Open Graph cards are 1200 × 630 pixels. Set the viewport to that size and send your template with the page's data filled in:
curl -X POST "https://shotkit.net/api/take" \
-H "X-Access-Key: YOUR_ACCESS_KEY" \
-H "Content-Type: application/json" \
-d '{"html":"<div style=\"box-sizing:border-box;width:1200px;height:630px;padding:72px;display:flex;flex-direction:column;justify-content:space-between;background:linear-gradient(135deg,#0f172a,#1e3a8a);color:#fff;font-family:system-ui\"><div style=\"font-size:28px;opacity:.7\">blog.example.com</div><div style=\"font-size:68px;font-weight:700;line-height:1.1\">How we cut our build times in half</div><div style=\"font-size:28px\">Ada Lovelace · 6 min read</div></div>","viewport_width":1200,"viewport_height":630,"format":"png"}' \
--fail-with-body -o shot.pngBecause the card is rendered by Chrome, anything a browser can draw works: CSS grid, web fonts, gradients, SVG, emoji, background-image photos and even a chart drawn by JavaScript.
Or render a route of your own site
Long HTML doesn't belong in a URL. The pattern that scales is a hidden route on your site, such as /og/post-42, that renders the card at 1200 × 630, and a screenshot of that URL:
curl -X POST "https://shotkit.net/api/take" \
-H "X-Access-Key: YOUR_ACCESS_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://blog.example.com/og/how-we-cut-build-times","viewport_width":1200,"viewport_height":630,"format":"png","cache":true,"cache_ttl":2592000}' \
--fail-with-body -o shot.pngThe template lives in your codebase, uses your real components and fonts, and you can open it in a browser while designing it.
Put the URL straight into og:image
A GET request with your access key is a valid image URL. Sign it so nobody can change its parameters, turn on caching, and use it as the tag's value:
<meta property="og:image" content="https://shotkit.net/api/take?access_key=…&url=https%3A%2F%2Fblog.example.com%2Fog%2Fhow-we-cut-build-times&viewport_width=1200&viewport_height=630&format=png&cache=true&cache_ttl=2592000&signature=…" />The first crawler that asks triggers the render; every later request is redirected to the cached file on the CDN and doesn't count toward your monthly screenshots. To have the image ready before anyone shares the link, request the same URL with response_type=empty when you publish.
Tips for cards that look sharp
- Fonts: load them with a
<link>to Google Fonts or an@font-facewith an absolute URL, and add"wait_until": ["networkidle0"]if they arrive late. - Retina:
device_scale_factor: 2renders 2400 × 1260 for crisp text on high-density screens. Most platforms downscale anyway, so 1× is usually enough and loads faster. - Safe area: some apps crop the card to a square or 1.91:1, so keep the title away from the edges.
- Format: PNG for flat colors and text, JPG for cards built around a photo. Both work everywhere.
Compared to Satori and @vercel/og
Satori-based generators turn JSX into SVG without a browser, so they're fast and run at the edge. In exchange they support a subset of CSS (flexbox but not grid, limited text layout), need fonts bundled as files, and can't run JavaScript. A screenshot API trades a few hundred milliseconds on the first render for full browser fidelity, and caching removes that cost for every view after it.
Frequently asked questions
What size should an Open Graph image be?
1200 × 630 pixels (a 1.91:1 ratio) works on Facebook, LinkedIn, X, Slack, Discord and iMessage. Keep the file under 5 MB; a PNG card at that size is usually well under 500 KB.
Will crawlers wait for an image that's rendered on demand?
Yes. The first request takes a few seconds and crawlers wait for it. To skip even that, warm the cache when you publish by requesting the same URL with response_type=empty.
Does every share or crawl count toward my quota?
No. With cache=true, only the first render counts. Repeat requests for the same URL are free cache hits until cache_ttl expires (up to 30 days).
How do I update an image after editing a post?
Add a cache_key such as the post's last-modified time to the URL. A new key is a new cache entry, so the next crawl renders the updated card.