Skip to content

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.

Terminal window
# .env (never commit it)
SHOTVIK_KEY_ID=... # the public 12-character key ID
SHOTVIK_SIGNING_SECRET=... # the key's signing secret, shown once when the key is created

Don’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).

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).

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>

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 build and 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.

Store your design once and pass its tpl_… ID as template:

Terminal window
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.

  1. Run astro build, then open dist/blog/<id>/index.html and find og:image. Astro writes & as &amp; inside the attribute, which is correct.
  2. Open the URL: you should get a 1200×630 PNG. The first request returns X-Cache: MISS and later ones HIT. Cache hits don’t count toward your quota.
  3. 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