Skip to content

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.

Terminal window
# .env.local (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 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.

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 sig is signed. That’s why cache and cache_ttl go through the helper rather than being appended afterwards.

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>
);
}
  • generateMetadata only 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 need metadataBase for 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.

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:

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}}…"}'
# → 201 {"id": "tpl_…", "name": "blog-card"}

For the template syntax and a full grid example, see the overview.

  1. Run next build && next start, open a post, and view the source: <meta property="og:image" content="https://api.shotvik.com/v1/og?…&amp;sig=…">.
  2. Open that URL (with &amp; as &): 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: with cacheTtl set, the image is stored on Shotvik’s Hetzner servers (EU) for that long, and served with Cache-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, update SHOTVIK_SIGNING_SECRET and rebuild or redeploy so pages carry newly signed URLs.
  • Missing font or image? Check the X-Shotvik-Warnings response header on the image URL, for example with curl -sD - -o /dev/null '<url>'. It lists skipped fonts and images (details).
  • Edge runtime: node:crypto isn’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