Type something to search...
Adding Open Graph Images With Sanity

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:

TagExample valueNotes
og:imagehttps://cdn.sanity.io/images/...Must be an absolute URL
og:image:width / og:image:height1200 / 630Lets platforms lay out the card before downloading
og:image:altA laptop showing the StudioRead by screen readers in some apps
og:image:typeimage/jpegOptional, but helpful
twitter:cardsummary_large_imageAsks 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 use auto('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: grid doesn't work, and every element with more than one child needs display: '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 explicit width and height. 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:

PrioritySourceWhen it applies
1seo.ogImageThe editor chose a specific social image
2Generated card with mainImage backgroundArticles and pages with a main image
3Generated card on a plain backgroundContent without any image
4app/opengraph-image.tsx with defaultOgImageEvery 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:

  1. Open the image directly: view the page source, copy the og:image URL and open it. You should see a 1200 by 630 PNG.
  2. 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.
  3. 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.
  4. Use platform tools: the Facebook Sharing Debugger and LinkedIn Post Inspector show the preview and can force a fresh scrape.
  5. Try a private message: paste the link into Slack or Discord to see the real card.
  6. Check it's public: images behind authentication, preview deployments with protection or a robots.txt that 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.

Tags :
Share :

Related Posts

Building a Static Site With Astro and Sanity

Building a Static Site With Astro and Sanity

To build a static site with Astro and Sanity, keep Astro's default output: 'static', configure @sanity/astro with useCdn: false, and fetch

Dive Deeper
Common Sanity Mistakes and How to Fix Them

Common Sanity Mistakes and How to Fix Them

The most common Sanity mistakes are using the CDN where you need fresh data, fetching whole documents with ..., leaving slugs unvalidated, ship

Dive Deeper
How to Create a Custom Sanity Plugin

How to Create a Custom Sanity Plugin

To create a custom Sanity plugin, wrap a piece of Studio configuration in definePlugin from the sanity package and add it to the plugins ar

Dive Deeper