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 idempotencyKey hash 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 existing short-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.