Dynamic OG images in Next.js (App Router) with full CSS
This guide builds a per-page og:image for a Next.js App Router blog. generateMetadata creates a signed Shotvik URL on the server, and crawlers fetch the PNG straight from Shotvik. The signing secret never reaches the browser.
Already using next/og (ImageResponse)? Vercel’s docs say @vercel/og, which next/og provides, uses Satori and Resvg and is already included in App Router projects (Vercel docs, checked 2026-10-03). Satori’s README lists the CSS it supports; display: grid, <style> tags and WOFF2 fonts aren’t in it (Satori README, checked 2026-10-03). If your design uses those, this guide renders it in headless Chromium instead. See the side-by-side overview.
1. Environment variables
Section titled “1. Environment variables”# .env.local (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 NEXT_PUBLIC_. In Next.js, non-NEXT_PUBLIC_ variables are only available on the server and aren’t inlined into the browser bundle (Next.js docs: environment variables). Your API key isn’t needed for signed links at all.
2. A server-only signing helper
Section titled “2. A server-only signing helper”lib/shotvik-og.ts:
import 'server-only';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 = process.env.SHOTVIK_KEY_ID; const secret = process.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 'server-only'makes the build fail if a Client Component ever imports this file (Next.js docs: preventing environment poisoning).- The helper uses
node:crypto, so call it from the Node.js runtime, which is the default for pages. - Each parameter name may appear only once, and every parameter except
sigis signed. That’s whycacheandcache_ttlgo through the helper rather than being appended afterwards.
3. Use it in generateMetadata
Section titled “3. Use it in generateMetadata”app/blog/[slug]/page.tsx:
import type { Metadata } from 'next';import { shotvikOgUrl } from '@/lib/shotvik-og';import { getPost } from '@/lib/posts'; // your own data source
type Props = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> { const { slug } = await params; const post = await getPost(slug);
const image = shotvikOgUrl({ template: 'article', // or your stored 'tpl_…' template vars: { category: post.category, title: post.title, author: post.author, date: post.date, site: 'example.com', }, cacheTtl: 604800, // 7 days });
return { title: post.title, description: post.excerpt, openGraph: { title: post.title, description: post.excerpt, images: [{ url: image, width: 1200, height: 630, alt: post.title }], }, twitter: { card: 'summary_large_image', images: [image] }, };}
export default async function Page({ params }: Props) { const { slug } = await params; const post = await getPost(slug); return ( <article> <h1>{post.title}</h1> </article> );}generateMetadataonly runs in Server Components (Next.js docs), so the secret stays on the server.- Open Graph image URLs must be absolute (Next.js docs:
openGraph). The Shotvik URL already is, so you don’t needmetadataBasefor it. - The URL is deterministic: the same template and variables give the same URL and the same cached image. Statically generated pages sign the URL at build time; dynamic pages sign it per request. Both work.
Using opengraph-image.tsx files? Next.js gives file-based metadata priority over generateMetadata, so remove that file for routes that use Shotvik.
4. Your own template
Section titled “4. Your own template”The built-in article template is fine to start with. For your own design (grid, brand font), store an HTML template once, then pass its 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}}…"}'# → 201 {"id": "tpl_…", "name": "blog-card"}For the template syntax and a full grid example, see the overview.
5. Check it
Section titled “5. Check it”- Run
next build && next start, open a post, and view the source:<meta property="og:image" content="https://api.shotvik.com/v1/og?…&sig=…">. - Open that URL (with
&as&): you should get a 1200×630 PNG. The first request returnsX-Cache: MISSand later onesHIT. Cache hits don’t count toward your quota. - Errors come back as JSON with a
code. See Troubleshooting.
- Caching: with
cacheTtlset, the image is stored on Shotvik’s Hetzner servers (EU) for that long, and served withCache-Control: public, max-age=…. Without it, every crawler fetch is a new, counted render. - Revoking the key or rotating its signing secret breaks all its signed links, though copies already cached by crawlers can stay up to
max-age. After a rotation, updateSHOTVIK_SIGNING_SECRETand rebuild or redeploy so pages carry newly signed URLs. - Missing font or image? Check the
X-Shotvik-Warningsresponse header on the image URL, for example withcurl -sD - -o /dev/null '<url>'. It lists skipped fonts and images (details). - Edge runtime:
node:cryptoisn’t available there. Keep metadata on the Node.js runtime, or port the helper to Web Crypto (crypto.subtle, HMAC SHA-256).
Shotvik is in private beta. Request access: support@shotvik.com · Privacy