
Adding Open Graph Images With Sanity
To add Open Graph images with Sanity, give your documents a social image field, crop that image to 1200 by 630 pixels with the Sanity image URL builder, and output it as the og:image meta tag with its width, height and alt text. For pages without a hand-picked image, generate one: in the Next.js App Router, an opengraph-image.tsx file in a route segment can fetch the document with GROQ and render a branded card with ImageResponse from next/og. Finish with a chain of fallbacks, from the editor's image to the main image to a generated card to a site-wide default, so every shared link has a preview.
A broken or badly cropped preview makes a link look careless, and Sanity already stores the titles, images and hotspots you need in structured fields. This guide covers the schema fields, sizing images with the URL builder, generating images dynamically with next/og, combining editor images with generated ones, fallbacks, caching and how to test what platforms will actually show.
How Platforms Read Social Images
When someone shares a link, the platform fetches the page and reads a few meta tags:
| Tag | Example value | Notes |
|---|---|---|
og:image | https://cdn.sanity.io/images/... | Must be an absolute URL |
og:image:width / og:image:height | 1200 / 630 | Lets platforms lay out the card before downloading |
og:image:alt | A laptop showing the Studio | Read by screen readers in some apps |
og:image:type | image/jpeg | Optional, but helpful |
twitter:card | summary_large_image | Asks X for the large image layout |
A 1200 by 630 image, a 1.91:1 ratio, works well across Facebook, LinkedIn, X, Slack, Discord and messaging apps. Use JPEG or PNG, keep the file to a few hundred kilobytes, and keep important text away from the edges because some apps crop the image. X falls back to og:image when there's no twitter:image, so one image usually covers everything.
Add Social Image Fields to the Schema
If you followed the SEO metadata guide, your seo object already has an ogImage field. Extend it with alt text and a dimension check, so editors know when an upload is too small:
// schemaTypes/objects/seo.ts (the ogImage field)
defineField({
name: 'ogImage',
title: 'Social share image',
type: 'image',
description: 'Shown when the page is shared. Leave empty to generate one automatically.',
options: { hotspot: true },
fields: [
defineField({
name: 'alt',
title: 'Alternative text',
type: 'string',
description: 'Describe the image for people who cannot see it.',
}),
],
validation: (rule) =>
rule.custom((value) => {
const ref = value?.asset?._ref
if (!ref) return true
const [width, height] = ref.split('-')[2].split('x').map(Number)
return width >= 1200 && height >= 630
? true
: `This image is ${width}×${height}. Use at least 1200×630 so it stays sharp.`
}).warning(),
}),
Image asset references include the original dimensions, as in image-abc123-2400x1600-jpg, so you can check the size without fetching the asset. It's a warning rather than an error, because a slightly small image is better than none. The image alt text guide covers writing useful descriptions.
Turning on hotspot matters more for social images than almost anywhere else. A 1.91:1 crop of a portrait photo throws away most of the picture, and the hotspot tells the URL builder which part to keep.
Store a Site-Wide Default
Add a default image to your settings singleton for pages that have nothing else:
// schemaTypes/siteSettings.ts
defineField({
name: 'defaultOgImage',
title: 'Default social image',
type: 'image',
options: { hotspot: true },
fields: [defineField({ name: 'alt', type: 'string', title: 'Alternative text' })],
}),
A singleton settings document is the natural home for it.
Size Images With the URL Builder
Never point og:image at the original upload. It might be a 6MB PNG at the wrong ratio. Ask the Sanity image CDN for exactly what platforms want:
// src/sanity/lib/ogImage.ts
import type { SanityImageSource } from '@sanity/image-url'
import { urlFor } from '@/sanity/lib/image'
export function ogImageUrl(source: SanityImageSource) {
return urlFor(source)
.width(1200)
.height(630)
.fit('crop')
.format('jpg')
.quality(85)
.url()
}
What each call does:
width(1200).height(630): the output size, giving the 1.91:1 ratio.fit('crop'): fills the frame and crops the overflow, centred on the hotspot when there is one.format('jpg'): an explicit format. Don't useauto('format')here, because some crawlers handle WebP or AVIF poorly.quality(85): a good balance between file size and sharpness for photos.
Pass the whole image object, not just the asset URL, or the builder can't apply the editor's crop and hotspot. Using the Sanity image URL builder explains the other methods and fit modes.
Query Images With Fallbacks
Resolve the fallback chain in GROQ, so the front end receives one image and one alt text:
// src/sanity/queries.ts
import { defineQuery } from 'next-sanity'
export const POST_OG_QUERY = defineQuery(`{
"post": *[_type == "post" && slug.current == $slug][0]{
title,
excerpt,
"customImage": seo.ogImage,
"customAlt": seo.ogImage.alt,
mainImage,
"author": author->name,
"category": categories[0]->title
},
"settings": *[_id == "siteSettings"][0]{ siteName, defaultOgImage }
}`)
Keep customImage and mainImage separate rather than merging them with coalesce(). As you'll see below, the generated card uses them differently: a custom image is shown as is, while the main image becomes a background behind the title.
Generate Images With next/og
The App Router has a file convention for this. Add opengraph-image.tsx next to a page, and Next.js renders it to a PNG and adds the og:image, size, type and alt tags for that route automatically. Inside, ImageResponse from next/og turns JSX and a subset of CSS into an image.
// src/app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { client } from '@/sanity/lib/client'
import { urlFor } from '@/sanity/lib/image'
import { ogImageUrl } from '@/sanity/lib/ogImage'
import { POST_OG_QUERY } from '@/sanity/queries'
export const alt = 'Article preview'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
const interBold = await readFile(join(process.cwd(), 'assets/Inter-Bold.ttf'))
export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const { post, settings } = await client
.withConfig({ stega: false })
.fetch(POST_OG_QUERY, { slug }, { next: { tags: ['post', `post:${slug}`] } })
const fonts = [{ name: 'Inter', data: interBold, weight: 700 as const, style: 'normal' as const }]
// 1. The editor chose an image: use it as it is
if (post?.customImage?.asset) {
return new ImageResponse(
<img src={ogImageUrl(post.customImage)} width={1200} height={630} alt="" />,
{ ...size },
)
}
// 2. Otherwise, generate a branded card
const background = post?.mainImage?.asset
? urlFor(post.mainImage).width(1200).height(630).fit('crop').format('jpg').blur(20).url()
: null
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
position: 'relative',
backgroundColor: '#0f172a',
fontFamily: 'Inter',
}}
>
{background && (
<img
src={background}
width={1200}
height={630}
alt=""
style={{ position: 'absolute', top: 0, left: 0, opacity: 0.35 }}
/>
)}
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
width: '100%',
padding: 72,
color: 'white',
}}
>
<div style={{ display: 'flex', fontSize: 28, color: '#93c5fd' }}>
{post?.category ?? settings?.siteName ?? 'Blog'}
</div>
<div style={{ display: 'flex', fontSize: 68, lineHeight: 1.1, maxWidth: 1000 }}>
{post?.title ?? settings?.siteName ?? 'Untitled'}
</div>
<div style={{ display: 'flex', fontSize: 28, color: '#cbd5e1' }}>
{post?.author ? `By ${post.author}` : settings?.siteName}
</div>
</div>
</div>
),
{ ...size, fonts },
)
}
A few things to know about ImageResponse:
- Flexbox only: layout uses flexbox and absolute positioning.
display: griddoesn't work, and every element with more than one child needsdisplay: 'flex'. - Fonts: only TTF, OTF and WOFF are supported, not WOFF2. Load them once at module level, as above, rather than on every request.
- Images:
<img>needs explicitwidthandheight. Request JPEG or PNG from Sanity, which is why both URLs set the format. - Bundle size: the limit is 500KB including fonts and inline assets, so fetch images by URL rather than importing them.
- Long titles: there's no automatic font scaling. Shrink the font size for long titles, for example
fontSize: post.title.length > 60 ? 56 : 68.
The stega: false setting matters if you use Visual Editing, because stega characters in the title would end up rendered in the image.
The alt export is a fixed string. To use the post title or the editor's alt text instead, export generateImageMetadata, which receives the route params and can return an alt per image.
How This Fits With generateMetadata
File-based metadata takes priority over generateMetadata. Once a route has opengraph-image.tsx, any openGraph.images you return from generateMetadata for that route is overridden. That's why the example handles the editor's custom image inside the image route: one place decides what the image is.
So in generateMetadata, keep returning titles and descriptions, but leave images out:
// src/app/blog/[slug]/page.tsx
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
const post = await metadataClient.fetch(POST_SEO_QUERY, { slug })
return {
title: post?.title,
description: post?.description,
openGraph: { type: 'article', title: post?.title, description: post?.description },
twitter: { card: 'summary_large_image' },
}
}
Make sure metadataBase is set in your root layout, so the generated image URL is absolute.
The alternative is a Route Handler such as app/api/og/route.tsx returning an ImageResponse, with generateMetadata choosing between the editor's image URL and /api/og?slug=.... It's more code, but gives you a URL you control.
Fallbacks for Every Page
Every route should end up with an image. A sensible order:
| Priority | Source | When it applies |
|---|---|---|
| 1 | seo.ogImage | The editor chose a specific social image |
| 2 | Generated card with mainImage background | Articles and pages with a main image |
| 3 | Generated card on a plain background | Content without any image |
| 4 | app/opengraph-image.tsx with defaultOgImage | Every route without its own image file |
The last row is a root-level opengraph-image.tsx. Image files apply to their segment and everything below it, so one at the root covers the home page, contact page and any route you forget:
// src/app/opengraph-image.tsx
import { ImageResponse } from 'next/og'
import { client } from '@/sanity/lib/client'
import { ogImageUrl } from '@/sanity/lib/ogImage'
export const alt = 'Example.com'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
export default async function Image() {
const settings = await client
.withConfig({ stega: false })
.fetch(`*[_id == "siteSettings"][0]{ siteName, defaultOgImage }`, {}, { next: { tags: ['siteSettings'] } })
if (settings?.defaultOgImage?.asset) {
return new ImageResponse(
<img src={ogImageUrl(settings.defaultOgImage)} width={1200} height={630} alt="" />,
{ ...size },
)
}
return new ImageResponse(
(
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#0f172a', color: 'white', fontSize: 80 }}>
{settings?.siteName ?? 'Example.com'}
</div>
),
{ ...size },
)
}
Caching and Revalidation
Generated images are cached by default, like other metadata routes, unless they use request-time APIs. The tagged fetches above mean you can refresh them the same way as your pages: a Sanity webhook on publish calls revalidateTag('post', 'max'), and the next request renders a fresh image. How to use Sanity webhooks covers setting one up.
The harder cache is the platforms' own. Facebook, LinkedIn and Slack store a link's preview, often for days. An editor-chosen image is easier here, because uploading a new image creates a new asset and therefore a new URL. A generated image keeps the same URL, so after changing a title you may need to ask platforms to scrape the page again.
Testing Your Images
Check images before anyone shares the link:
- Open the image directly: view the page source, copy the
og:imageURL and open it. You should see a 1200 by 630 PNG. - Check the tags:
curl -s https://www.example.com/blog/my-post | grep -o '<meta property="og:[^>]*>'should list the image, width, height and alt. - Test the edge cases: a post with a custom image, one with only a main image, one with no image and a very long title.
- Use platform tools: the Facebook Sharing Debugger and LinkedIn Post Inspector show the preview and can force a fresh scrape.
- Try a private message: paste the link into Slack or Discord to see the real card.
- Check it's public: images behind authentication, preview deployments with protection or a
robots.txtthat blocks the route won't load for crawlers.
The opengraph-image and twitter-image reference lists every option of the file convention. If your front end uses Astro rather than Next.js, the same ideas apply with Satori at build time, as shown in Open Graph images in Astro.
FAQ: Open Graph Images With Sanity
1200 by 630 pixels, a 1.91:1 ratio, works well on most platforms. With Sanity, request that size from the image CDN with width(1200), height(630) and fit('crop'), and keep important content away from the edges in case an app crops it further.
Usually because the hotspot isn't set, or the builder was given a URL instead of the image object. Enable hotspot on the field, ask editors to set it, and pass the full image object to urlFor so the crop and hotspot are applied.
File-based metadata takes priority. If the route, or a parent segment, has an opengraph-image file, it overrides the images returned by generateMetadata. Handle custom images inside the image file, or remove the file and set images in generateMetadata instead.
It's safer not to. Some crawlers and apps don't display WebP or AVIF previews reliably, so request JPEG or PNG with format('jpg') or format('png') rather than auto('format').
Platforms cache previews. Use the Facebook Sharing Debugger or LinkedIn Post Inspector to scrape the page again. A new image asset in Sanity gets a new URL, which helps, but generated images keep their URL, so a re-scrape is often needed.
Usually not. X falls back to og:image when twitter:image is missing. Set the twitter card type to summary_large_image so it uses the large layout, and add a twitter-image file only if you want a different design for X.
Conclusion
Good social images with Sanity come from a few small decisions. Give editors an optional social image with a hotspot and alt text, crop everything to 1200 by 630 with the URL builder, and resolve fallbacks in GROQ so the front end gets one clear answer. For everything editors don't customise, opengraph-image.tsx and ImageResponse turn your content into branded cards automatically.
Add a root-level image so no route goes without a preview, revalidate generated images with the same webhook that refreshes your pages, and test the real cards before a big launch. Once it's set up, every new post gets a polished preview without anyone opening a design tool.


