Sanity: OG images on publish
Sanity's GROQ-powered webhooks fire on publish with exactly the fields you project. Pair one with a ~40-line handler and every document gets a branded, permanently-hosted OG image the moment it goes live. Nothing renders at request time.
sanity publish
→ GROQ webhook (projection: _id, title, slug)
→ /api/og-hook on your frontend
├─ template (default, 0 credits, sync)
└─ AI (opt-in via a boolean field, async + webhook)
→ client.patch(_id).set({ 'seo.ogImageUrl': url })1. Add the field
Give your SEO object somewhere to store the result:
// schemas/seo.ts
defineField({ name: 'ogImageUrl', type: 'url', readOnly: true }),
defineField({
name: 'aiOgImage',
title: 'Generate OG image with AI',
type: 'boolean',
initialValue: false, // templates by default; AI only where it matters
}),2. Create the webhook
In sanity.io/manage → API → Webhooks: trigger on create and update, filter _type == "post" && !(_id in path("drafts.**")), projection:
{ _id, title, "slug": slug.current, "ai": seo.aiOgImage, "existing": seo.ogImageUrl }Point it at https://yoursite.com/api/og-hook and set a webhook secret.
3. The handler
Next.js App Router version (adapt freely, it's one fetch + one patch):
// app/api/og-hook/route.ts
import { createClient } from '@sanity/client';
import { isValidSignature, SIGNATURE_HEADER_NAME } from '@sanity/webhook';
import { createHash } from 'crypto';
const sanity = createClient({
projectId: process.env.SANITY_PROJECT_ID!,
dataset: 'production',
token: process.env.SANITY_WRITE_TOKEN!, // needs write access
apiVersion: '2025-01-01',
useCdn: false,
});
export async function POST(req: Request) {
const body = await req.text();
const sig = req.headers.get(SIGNATURE_HEADER_NAME) ?? '';
if (!(await isValidSignature(body, sig, process.env.SANITY_WEBHOOK_SECRET!))) {
return new Response('bad signature', { status: 401 });
}
const { _id, title, slug, ai, existing } = JSON.parse(body);
if (!title || existing) return Response.json({ skipped: true });
if (!ai) {
// Default path: deterministic template, 0 credits, synchronous.
const res = await fetch('https://ogimagen.com/api/v1/templates', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.OGIMAGEN_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
template: 'blog',
title,
siteName: 'yoursite.com',
brandKitId: process.env.OGIMAGEN_BRAND_KIT_ID, // logo + color fill in
}),
});
const { url } = await res.json();
await sanity.patch(_id).set({ 'seo.ogImageUrl': url }).commit();
return Response.json({ url });
}
// Hero path: AI generation. Idempotent + webhook, so republish is free
// and we never poll.
await fetch('https://ogimagen.com/api/v1/generations', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.OGIMAGEN_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
title,
displayText: title,
quality: 'flash',
variants: 1,
idempotencyKey: createHash('sha256').update(`${slug}:${title}`).digest('hex'),
webhookUrl: `https://yoursite.com/api/og-done?docId=${_id}`,
webhookSecret: process.env.OGIMAGEN_WEBHOOK_SECRET,
}),
});
return Response.json({ pending: true });
}4. Receive the finished image
// app/api/og-done/route.ts
import { createHmac, timingSafeEqual } from 'crypto';
// ...same sanity client as above
export async function POST(req: Request) {
const body = await req.text();
const ts = req.headers.get('x-ogimagen-timestamp') ?? '';
const sig = req.headers.get('x-ogimagen-signature') ?? '';
const expected = 'sha256=' + createHmac('sha256', process.env.OGIMAGEN_WEBHOOK_SECRET!)
.update(`${ts}.${body}`).digest('hex');
if (sig.length !== expected.length ||
!timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return new Response('bad signature', { status: 401 });
}
const { generation } = JSON.parse(body);
const docId = new URL(req.url).searchParams.get('docId')!;
if (generation.status === 'done') {
await sanity.patch(docId).set({ 'seo.ogImageUrl': generation.urls.og }).commit();
}
return Response.json({ ok: true });
}5. Render the meta tag
// app/posts/[slug]/page.tsx
export async function generateMetadata({ params }) {
const post = await getPost(params.slug);
return {
openGraph: {
images: post.seo?.ogImageUrl ? [post.seo.ogImageUrl] : [],
},
};
}Notes
- The
idempotencyKeyhash of slug + title means an unchanged republish returns the original image free of charge; an edited title generates a fresh one. - URLs are permanent CDN links, no expiry, so the
existingshort-circuit in step 3 is safe. - Studio plugin ("Generate OG" button in the document pane) is on the roadmap; the webhook flow above is the supported path today.
Full parameter reference in the API docs. Other platforms: all recipes.