Dynamic OG images without Vercel
You can generate Open Graph images on any host. Here are four ways to do it, what each one asks of you in setup and upkeep, and where Shotvik fits among them.
Sources checked on 4 October 2026. Statements about Satori and Vercel come from their own docs and are linked where they appear.
An Open Graph image is the 1200×630 picture that chat apps and social networks show when someone shares a link. “Dynamic” means one image per page, with that page’s title, price or author. Every option below gives you a PNG URL to put in <meta property="og:image">.
Option 1: Satori and resvg on your own server
Satori is the library that @vercel/og builds on. It’s open source under the Mozilla Public License 2.0 (LICENSE, checked 2026-10-04), and its README says it can be used in the browser, in Node.js (>= 16) and in Web Workers (Satori README, Runtime Support, checked 2026-10-04). Satori turns a React-like element tree into SVG; resvg-js turns that SVG into a PNG.
// og.mjs (npm i satori @resvg/resvg-js)
import satori from 'satori';
import { Resvg } from '@resvg/resvg-js';
import { readFile, writeFile } from 'node:fs/promises';
const font = await readFile('./fonts/Inter-Bold.ttf'); // TTF, OTF or WOFF
const svg = await satori(
{
type: 'div',
props: {
style: { display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: 'center',
background: '#0f172a', color: 'white', fontSize: 64, fontFamily: 'Inter' },
children: 'Hello from my own server',
},
},
{ width: 1200, height: 630, fonts: [{ name: 'Inter', data: font, weight: 700, style: 'normal' }] },
);
await writeFile('og.png', new Resvg(svg).render().asPng());
Good fit: a card made of text, a logo and a background in a flexbox layout, generated inside your own Node.js process. What you take on: loading font files yourself, keeping to the CSS that the Satori README documents (checked 2026-10-04), and serving and caching the PNGs.
Option 2: pre-render at build time
If your pages change when you deploy, render one PNG per page during the build and ship them as static files next to your HTML. Any of the other options can do the rendering. There’s nothing to run in production, and crawlers get a plain file from your own host. The trade-off: a changed title or price shows up only after the next build.
Option 3: run a headless browser yourself
Render an HTML template in Chromium with Playwright or Puppeteer and take a 1200×630 screenshot. Any CSS that Chromium supports works, including grid, <style> blocks and web fonts.
// npm i playwright && npx playwright install chromium
import { chromium } from 'playwright';
const html = `<!doctype html><html><head><style>
body { margin: 0; width: 1200px; height: 630px; display: grid; place-items: center;
background: #0f172a; color: white; font: 700 64px system-ui, sans-serif; }
</style></head><body>Hello from Chromium</body></html>`;
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1200, height: 630 } });
await page.setContent(html, { waitUntil: 'load' });
await page.screenshot({ path: 'og.png' });
await browser.close();
Good fit: you already run servers and want full control. What you take on: a Chromium install and its updates, memory per render, a queue so a burst of crawler traffic doesn’t start dozens of browsers, fonts installed on the server, and, if templates can load remote URLs, blocking requests to private and internal addresses.
Option 4: an image API such as Shotvik
Shotvik is option 3 run as a service. You send a template name and variables, or a product page URL, and get a 1200×630 PNG back.
- Templates in HTML and CSS, rendered in headless Chromium: the built-in
basicandarticletemplates, or your own stored template with{{variables}}. - Signed
og:imageURLs. The URL carries your public key ID and an HMAC signature, never your API key, so you can put it straight into your HTML. Examples in JavaScript, Python and PHP. - Product pages without a template:
/v1/og/from-urlreads the product’s name, price and image from the page. See the live demo. - Opt-in cache. With
cache=true, a rendered image is kept for up to 30 days, and cache hits don’t count toward your quota. Without it, nothing is stored. - Processing and storage in the EU, on Hetzner servers in Helsinki, Finland. Cloudflare only provides DNS; API traffic goes straight to our server. Details in the privacy notice.
The trade-offs: it’s an external dependency, an uncached image is a network call, and renders count toward a monthly quota. The free plan has 100 renders a month, 10 uncached renders per minute (cache hits don’t count) and 2 concurrent renders, with no card needed. Paid plans will come later.
curl https://api.shotvik.com/v1/og \
-H "Authorization: Bearer $SHOTVIK_API_KEY" \
-H "Content-Type: application/json" \
-d '{"template": "basic", "vars": {"title": "Hello", "subtitle": "My first OG image", "site": "example.com"}}' \
--fail-with-body --output og.png
Which one to pick
| Your situation | A good starting point |
|---|---|
| Next.js App Router, a flexbox card | ImageResponse from next/og; see Next.js OG images |
| Any Node.js host, a flexbox card, everything in your own process | Option 1: Satori and resvg |
| A static site that’s rebuilt when content changes | Option 2: pre-render at build time |
| Full CSS, you run servers already and want full control | Option 3: your own headless browser |
| Full CSS without running Chromium, several stacks, or product URLs | Option 4: an image API such as Shotvik |
For a sourced, point-by-point look at the CSS and font support each project documents, see the side-by-side overview in the docs.
Whatever you choose
- Use an absolute
httpsURL inog:image, and addog:image:widthandog:image:height. - Keep the image at 1200×630 (about 1.91:1).
- Platforms cache previews. When you change an image, refresh it with the tools in our link preview checklist.