Dynamic OG images in Astro with full CSS
This guide gives every page of an Astro blog its own og:image. The page’s frontmatter builds a signed Shotvik URL, and crawlers fetch the PNG straight from Shotvik. Frontmatter runs on the server (at build time for static pages, per request for on-demand pages), so the signing secret never reaches the browser.
Templates are HTML and CSS rendered in headless Chromium, so CSS grid, <style> blocks and WOFF2 web fonts work. For what Satori’s documentation lists as supported, see the side-by-side overview.
1. Environment variables
Section titled “1. Environment variables”# .env (never commit it)SHOTVIK_KEY_ID=... # the public 12-character key IDSHOTVIK_SIGNING_SECRET=... # the key's signing secret, shown once when the key is createdDon’t prefix them with PUBLIC_. In Astro, unprefixed variables are server-side only, and only PUBLIC_ variables are available in client-side code (Astro docs: environment variables).
2. A signing helper
Section titled “2. A signing helper”src/lib/shotvik-og.ts:
import { createHmac } from 'node:crypto';
export interface OgImageOptions { /** 'basic', 'article' or a stored template ID ('tpl_…'). */ template: string; /** Template variables: max 30, names [A-Za-z0-9_], values up to 1,000 characters. */ vars?: Record<string, string>; /** Enables the opt-in cache; 60–2592000 seconds (max 30 days, provisional). */ cacheTtl?: number;}
export function shotvikOgUrl({ template, vars = {}, cacheTtl }: OgImageOptions): string { const kid = import.meta.env.SHOTVIK_KEY_ID; const secret = import.meta.env.SHOTVIK_SIGNING_SECRET; if (!kid || !secret) throw new Error('SHOTVIK_KEY_ID and SHOTVIK_SIGNING_SECRET must be set');
const params: [string, string][] = [['kid', kid], ['template', template]]; for (const [name, value] of Object.entries(vars)) params.push([`vars[${name}]`, value]); if (cacheTtl !== undefined) params.push(['cache', 'true'], ['cache_ttl', String(cacheTtl)]);
// Canonical query: encodeURIComponent each name and value, sort by encoded name, join with '&'. const canonical = params .map(([k, v]) => [encodeURIComponent(k), encodeURIComponent(v)] as const) .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)) .map(([k, v]) => `${k}=${v}`) .join('&'); const sig = createHmac('sha256', secret).update(`GET\n/v1/og\n${canonical}`).digest('base64url'); return `https://api.shotvik.com/v1/og?${canonical}&sig=${sig}`;}Import this file only from frontmatter, endpoints or other server code, never from a <script> tag or a client-side framework component. It uses node:crypto, which is available at build time and with the Node adapter. If you render on demand on a runtime without node:crypto, port the helper to Web Crypto (crypto.subtle, HMAC SHA-256).
3. Put the tags in your layout
Section titled “3. Put the tags in your layout”src/layouts/BaseLayout.astro:
---interface Props { title: string; description?: string; ogImage: string;}const { title, description, ogImage } = Astro.props;---<html lang="en"> <head> <meta charset="utf-8" /> <title>{title}</title> {description && <meta name="description" content={description} />} <meta property="og:title" content={title} /> <meta property="og:image" content={ogImage} /> <meta property="og:image:width" content="1200" /> <meta property="og:image:height" content="630" /> <meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:image" content={ogImage} /> </head> <body> <slot /> </body></html>4. Build the URL per page
Section titled “4. Build the URL per page”src/pages/blog/[id].astro, following Astro’s content collections routing pattern:
---import { getCollection, render } from 'astro:content';import BaseLayout from '../../layouts/BaseLayout.astro';import { shotvikOgUrl } from '../../lib/shotvik-og';
export async function getStaticPaths() { const posts = await getCollection('blog'); return posts.map((post) => ({ params: { id: post.id }, props: { post } }));}
const { post } = Astro.props;const { Content } = await render(post);
const ogImage = shotvikOgUrl({ template: 'article', // or your stored 'tpl_…' template vars: { title: post.data.title, author: post.data.author, date: post.data.pubDate.toISOString().slice(0, 10), site: 'example.com', }, cacheTtl: 604800, // 7 days});---<BaseLayout title={post.data.title} description={post.data.description} ogImage={ogImage}> <h1>{post.data.title}</h1> <Content /></BaseLayout>This assumes a blog collection with title, description, author and pubDate fields; adjust it to your schema.
- Static build (default): URLs are signed once during
astro buildand written into the HTML. Shotvik renders each image on the first crawler fetch, then serves it from the cache. - On-demand rendering: the same frontmatter runs per request. The URL is deterministic (same template and variables, same URL), so the cache still applies.
5. Your own template
Section titled “5. Your own template”Store your design once and pass its tpl_… ID as template:
curl https://api.shotvik.com/v1/templates \ -H "Authorization: Bearer $SHOTVIK_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "blog-card", "html": "<!doctype html>…{{title}}…"}'For the syntax ({{name}}, {{name|fallback}}, always HTML-escaped) and a full grid example with a web font, see the overview.
6. Check it
Section titled “6. Check it”- Run
astro build, then opendist/blog/<id>/index.htmland findog:image. Astro writes&as&inside the attribute, which is correct. - Open the URL: you should get a 1200×630 PNG. The first request returns
X-Cache: MISSand later onesHIT. Cache hits don’t count toward your quota. - Errors come back as JSON with a
code. See Troubleshooting.
Caching and data: with cacheTtl set, images are stored on Shotvik’s Hetzner servers in the EU for at most that long (max 30 days, provisional). Without it, each crawler fetch is a new, counted render.
Rotated the signing secret? Old links stop working right away. Update SHOTVIK_SIGNING_SECRET and rebuild (or redeploy) so the pages carry newly signed URLs.
Missing font or image? Check the X-Shotvik-Warnings header on the image URL (for example with curl -sD - -o /dev/null '<url>'). It lists skipped fonts and images (details).
Shotvik is in private beta. Request access: support@shotvik.com · Privacy