OG image generator API with full CSS — an @vercel/og alternative
An Open Graph (OG) image is the 1200×630 picture that Slack, LinkedIn, X, WhatsApp and others show when someone shares your link. “Dynamic” means generating it per page (title, author, date) instead of designing each one by hand.
This guide puts two common approaches side by side and shows how Shotvik works. For framework code, go straight to:
Option A: @vercel/og / Satori
Section titled “Option A: @vercel/og / Satori”According to Vercel’s docs, @vercel/og uses Satori and Resvg to convert HTML and CSS into PNG, and it’s already included in Next.js App Router projects, where you import ImageResponse from next/og (Vercel docs, checked 2026-10-03). Satori is open source under the Mozilla Public License 2.0 (Satori LICENSE, checked 2026-10-03).
It’s a good fit when:
- your card design fits a flexbox layout (text, a logo, a background image);
- you want to generate images inside your own app, without an external service;
- you deploy on Vercel: Vercel’s docs say
@vercel/ogadds the headers to cache generated images on the CDN (Vercel docs, checked 2026-10-03).
Option B: render HTML in a headless browser
Section titled “Option B: render HTML in a headless browser”If your design uses CSS grid, <style> blocks, your existing stylesheet, calc(), z-index or WOFF2 fonts, you can render the card as a web page in a headless browser, for example with Playwright or Puppeteer on your own server. Shotvik does this as an API:
- CSS as Chromium renders it. Templates are plain HTML documents rendered in headless Chromium, so grid,
<style>and@font-facework. Both built-in templates use CSS grid. - Signed
og:imagelinks. Crawlers fetch aGETURL that carries your public key ID and an HMAC signature, never your API key. Changing any parameter breaks the signature. - Templates with variables. Store a template once, then pass
vars[title]=…per page. - Opt-in cache. Crawlers fetch the same image repeatedly. With
cache=true, Shotvik keeps the PNG for up to 30 days (provisional), and cache hits don’t count toward your quota. - Processing and storage in the EU. Rendering and the cache run on Hetzner servers in the EU, and the API isn’t behind a CDN.
Trade-offs to weigh:
- it’s an external service, so each uncached image is a network call to Shotvik;
- renders count toward a monthly quota (plans);
- a first render takes longer than serving a cached file, so use the cache for public links.
Side by side
Section titled “Side by side”What each project’s documentation says it supports. Satori and Vercel facts are quoted or paraphrased from their docs, checked 2026-10-03; Shotvik facts are from the Shotvik API spec.
| Topic | Satori / @vercel/og, per their docs | Shotvik |
|---|---|---|
| How a card is rendered | Satori uses the Yoga Flexbox layout engine and its README says it’s “not a complete CSS implementation” but supports “a subset of the spec that covers most common CSS features” (Satori README, CSS, checked 2026-10-03). | HTML and CSS rendered in headless Chromium. |
display |
flex, block, contents, none, -webkit-box (Satori README, CSS, checked 2026-10-03). Vercel’s docs: “Advanced layouts (display: grid) will not work.” (Vercel docs, checked 2026-10-03) |
Values Chromium supports, including grid. |
| Styles | No <style> tags or external resources via <link> or <script> (Satori README, HTML Elements, checked 2026-10-03). |
<style> blocks in the template. External stylesheets, fonts and images load from public URLs. |
calc and z-index |
“calc isn’t supported”; no z-index, later elements are painted on top (Satori README, CSS, checked 2026-10-03). |
As in Chromium. |
| Fonts | TTF, OTF and WOFF; “WOFF2 is not supported at the moment”; font data is passed in as a buffer (Satori README, Fonts, checked 2026-10-03). | @font-face with any format Chromium loads, including WOFF2, from a public URL. |
| Mixed LTR/RTL text | “Full Unicode bidirectional layout is not yet supported” (Satori README, Language and Typography, checked 2026-10-03). | As in Chromium. |
| Match with a browser | Satori “does not guarantee that the SVG will 100% match the browser-rendered HTML output” (Satori README, HTML Elements, checked 2026-10-03). | Rendered by a browser (Chromium). |
| Size limits | On Vercel, a maximum bundle size of 500 KB, including JSX, CSS, fonts and images (Vercel docs, checked 2026-10-03). | Template up to 1,048,576 bytes; per render up to 25 MiB downloaded and 400 requests. |
| Where it runs | Satori runs in the browser, Node.js (>= 16) and Web Workers (Satori README, Runtime Support, checked 2026-10-03). Vercel’s docs describe @vercel/og in Vercel Functions on the Node.js runtime (Vercel docs, checked 2026-10-03). |
Shotvik’s API on Hetzner (EU). Output is a 1200×630 PNG. |
How it works with Shotvik
Section titled “How it works with Shotvik”1. Pick or write a template
Section titled “1. Pick or write a template”The output is always 1200×630 PNG.
- Built-in:
basic(variablestitle,subtitle,site,accent) andarticle(category,title,author,date,site). - Your own: store an HTML template once with
POST /v1/templatesand use the returnedtpl_…ID.
curl https://api.shotvik.com/v1/templates \ -H "Authorization: Bearer $SHOTVIK_API_KEY" \ -H "Content-Type: application/json" \ -d @template.json# → 201 {"id": "tpl_…", "name": "blog-card"}A template that uses grid and a web font (template.json holds {"name": "blog-card", "html": "<the HTML below>"}):
<!doctype html><html><head><meta charset="utf-8"><style> @font-face { font-family: Brand; src: url('https://example.com/fonts/brand.woff2') format('woff2'); } html, body { margin: 0; width: 1200px; height: 630px; } body { display: grid; grid-template-columns: 1fr 320px; grid-template-rows: 1fr auto; gap: 32px; padding: 64px; box-sizing: border-box; font-family: Brand, system-ui, sans-serif; background: #0f172a; color: #fff; } h1 { grid-column: 1; align-self: center; margin: 0; font-size: 64px; line-height: 1.1; } .badge { grid-column: 2; grid-row: 1 / span 2; border-radius: 24px; background: {{accent|#0ea5e9}}; } footer { grid-column: 1; font-size: 28px; opacity: .8; }</style></head><body> <h1>{{title|Untitled}}</h1> <div class="badge"></div> <footer>{{author}} · {{site}}</footer></body></html>Template syntax:
{{name}}inserts a variable, and{{name|fallback}}uses the fallback when the variable is missing.- Values are always HTML-escaped. There’s no logic and no raw HTML output.
- Limits:
- at most 30 variables;
- variable names use
A–Z a–z 0–9 _(up to 40 characters); - values are up to 1,000 characters;
- the template is at most 1,048,576 bytes.
External resources (fonts, images) are loaded through the same safety checks as URL screenshots:
- only public
http(s)addresses; - a budget of 25 MiB and 400 requests per render;
- a 30-second render timeout.
A resource that’s blocked or over budget is dropped (e.g. you get a fallback font), and the render continues. Skipped fonts and images are listed in the X-Shotvik-Warnings response header, for example font:blocked:1 (format). Host fonts and images somewhere public.
You can test a template without a public link: POST /v1/og with your API key and {"template": "tpl_…", "vars": {…}} (or inline "html") returns the PNG. See the OG images reference.
2. Build a signed URL on your server
Section titled “2. Build a signed URL on your server”The image URL that goes into <meta property="og:image"> looks like this:
https://api.shotvik.com/v1/og?cache=true&cache_ttl=604800&kid=<key_id>&template=tpl_…&vars%5Btitle%5D=Hello&sig=<signature>kidis your public key ID. The signing secret stays on your server. It’s shown once, when the key is created (details).sig = base64url(HMAC-SHA256(signing_secret, "GET\n/v1/og\n" + canonical_query)). The canonical query is every parameter exceptsig, encoded withencodeURIComponentrules, sorted by name and joined with&.- Optional
exp(Unix seconds) makes the link expire with403 signature_expired. Crawlers re-fetch images later, so forog:imagelinks you usually leaveexpout. - Revoking or blocking the key, or rotating its signing secret, invalidates all of its links.
Ready-made helpers are in the Next.js and Astro guides. Python and PHP versions are in Authentication → Signed URLs.
3. Turn on the cache for public links
Section titled “3. Turn on the cache for public links”Add cache=true and cache_ttl (60–2,592,000 seconds, default 86,400) to the signed parameters:
- The first fetch renders the image (
X-Cache: MISS). Later fetches with the same template and variables are served from the cache on Shotvik’s Hetzner servers (X-Cache: HIT) and don’t count toward your quota. - The response carries
Cache-Control: public, max-age=<cache_ttl>, so browsers and crawlers can keep their own copies, even after you revoke the key. - Without
cache=true, each fetch is a new render that counts, and the response isCache-Control: private, no-store.
The maximum TTL of 30 days is provisional.
Test your tags
Section titled “Test your tags”- Open the signed URL in a browser: you should get a 1200×630 PNG. A JSON error tells you what’s wrong (Errors).
- Check
<head>in your page’s HTML output forog:imageandtwitter:image. In HTML,&in the URL appears as&, which is correct. - Use the platforms’ own share/preview debuggers to see what they fetch.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Likely cause |
|---|---|
401 invalid_signature |
The parameters were changed after signing, a parameter appears twice, the wrong or a rotated secret was used, or the encoding differs from encodeURIComponent. |
403 signature_expired |
The exp timestamp has passed. Leave it out for og:image links. |
404 template_not_found |
The tpl_… ID isn’t in the account that owns kid. |
| Fallback font or missing image | Check X-Shotvik-Warnings on the image response (curl -sD - -o /dev/null '<url>' prints the headers). font:blocked or image:blocked: the URL isn’t a public address on port 80/443. …:budget: the page loads more than 25 MiB or 400 requests. No header: the @font-face src is wrong, the file doesn’t exist, or its server couldn’t be reached. |
| Old image keeps showing | Your cache entry (up to cache_ttl) or the platform’s own cache. Changing a variable produces a new image. |
Shotvik is in private beta. Request access: support@shotvik.com · Privacy