# Never "design" an OG again

> How to render 1200x630, 1080x1080, and 1080x1350 social images from one next/og template in turbo-start-sanity, with real render timings.

**TL;DR:** One Satori template in turbo-start-sanity's /api/og route renders 1200x630 for link unfurls, 1080x1080 for feeds, and 1080x1350 for portrait posts. Layout costs 2 to 33ms, rasterizing about 55ms warm, and the CDN serves repeats in about 100ms. AI-generated images also carry C2PA metadata that platforms read, so strip it before re-uploading.

**Published:** 2026-07-23 | **Updated:** 2026-08-19 | **Categories:** Sanity, Next.js
---

Every image on this site is generated. The Victorian cat heroes, the OG cards, the square crops for socials, all of it. We covered the basics back in 2023 and [that walkthrough](/blog/dynamic-open-graph-with-sanity-and-next-js) is still up and still correct, but it only covers the standard `1200x630` unfurl card, because back then that was pretty much all anyone asked for. That stopped being true a while ago. A link unfurl wants `1200x630`, an Instagram feed post wants `1080x1350`, a square crop wants `1080x1080`, and I'm really not going to hand-design three versions of every hero image for every post we publish, that's what the template is for.

So this is the 2026 version of that post, where the same Satori template renders all three sizes from the one route. Everything below comes straight out of [turbo-start-sanity](https://github.com/robotostudio/turbo-start-sanity), our open-source Next.js and Sanity starter, so you can just rip it off and use it in your own project. We ran all the renders and benchmarks ourselves while writing this.

## The template already takes width and height

turbo-start-sanity ships an OG route at `apps/web/src/app/api/og/route.tsx`. It fetches the page's SEO data from Sanity and serves the editor's dedicated share image if they uploaded one. Otherwise it falls back to a generated card with a dark background, the site title top left, a type pill for blog posts, and the page title in 76px Inter pinned to the bottom.

The bit I think most people miss is in `og-config.ts`, because the route already accepts dimension overrides from the query string.

```tsx
// apps/web/src/app/api/og/og-config.ts (as shipped today)
const ogImageDimensions = {
  width: 1200,
  height: 630,
};

export const getOgMetaData = (searchParams: URLSearchParams) => {
  const width = searchParams.get("width") as string;
  const height = searchParams.get("height") as string;

  const ogWidth = Number.isNaN(Number.parseInt(width, 10))
    ? ogImageDimensions.width
    : Number.parseInt(width, 10);

  const ogHeight = Number.isNaN(Number.parseInt(height, 10))
    ? ogImageDimensions.height
    : Number.parseInt(height, 10);

  return { width: ogWidth, height: ogHeight };
};
```

So `/api/og?type=blog&id=...&width=1080&height=1080` renders today, unmodified. We put a live copy of this exact template at [our OG image generator](/tools/og-image-generator) if you want to have a play without cloning anything. The problem is that raw width and height params leave you with a 76px headline floating in a `1080x1080` square, with padding that was designed for a landscape card. It looks lost. So most of the work here is getting one template to look right at every size.

## Adding the ratio presets

First, swap the free-form dimensions for named presets. I'm a bit paranoid about the free-form version, because anyone with the URL can request a `4000x4000` render on your compute. With presets the route will only render the three sizes we've actually defined, and each preset carries the label that ends up in the footer.

```tsx
// apps/web/src/app/api/og/og-config.ts
export const RATIOS = {
  og: { width: 1200, height: 630, label: "og 1.91:1" },
  square: { width: 1080, height: 1080, label: "feed 1:1" },
  portrait: { width: 1080, height: 1350, label: "portrait 4:5" },
} as const;

export type RatioName = keyof typeof RATIOS;

export const getOgMetaData = (searchParams: URLSearchParams) => {
  const ratio = searchParams.get("ratio") as RatioName | null;
  return RATIOS[ratio ?? "og"] ?? RATIOS.og;
};
```

Then the template itself. Rather than trying to scale one landscape layout up and down, we made the title do all the visual work, so uppercase lines packed tight and anchored to the bottom of the canvas, the quoted word on an inline white highlight, and the width and height stamped in the footer.

There are two magic numbers in here, and both of them took a fair bit of trial and error. The first one, `CHAR_W` at 0.72, is the average advance width of Inter ExtraBold uppercase as a fraction of the font size, which we measured against real renders after 0.6 clipped. Dividing the inner width by a line's character count times that number sizes the line to span the canvas. The second is a packed-height fit, which stops the title stack shoving the footer off the squatter canvases. Every line then shares one size and one gap, so the rhythm stays the same across all three ratios.

```tsx
// apps/web/src/app/api/og/route.tsx
const CHAR_W = 0.72;

// Wide canvases get few long lines, tall ones get more short lines.
function stackLines(title: string, targetLines: number): string[] {
  const words = title.toUpperCase().split(" ");
  const budget = Math.ceil(words.join(" ").length / targetLines);
  const lines: string[] = [];
  for (const word of words) {
    const last = lines.at(-1);
    if (last && `${last} ${word}`.length <= budget) {
      lines[lines.length - 1] = `${last} ${word}`;
    } else {
      lines.push(word);
    }
  }
  return lines;
}

const brutalistCard = ({ title, siteTitle, width, height, label }: CardProps) => {
  const pad = Math.round(width * 0.04);
  const inner = width - pad * 2;
  const targetLines = Math.min(5, Math.max(2, Math.round(height / (width * 0.28))));
  const lines = stackLines(title, targetLines);
  const chrome = width * 0.05 + pad * 2.4; // header + footer + rules
  const availH = height - pad * 2 - chrome;
  // One size for every line: the tightest width fit wins, so the longest
  // line spans the canvas and the rest match it exactly. Mixed sizes read
  // as a bug the moment an inverted bar sits next to a solid line. The
  // height budget assumes a packed stack: n glyph boxes at 0.85 line
  // height plus (n - 1) gaps of 0.15em.
  const packedFit = Math.floor(
    availH / (lines.length * 0.85 + (lines.length - 1) * 0.15)
  );
  const fontSize = Math.min(
    packedFit,
    ...lines.map((line) => Math.floor((inner - pad * 0.6) / (line.length * CHAR_W)))
  );
  const gap = Math.round(fontSize * 0.15);

  return (
    <div
      style={{ backgroundColor: "#0A0A0A", fontFamily: "Inter", padding: pad }}
      tw="flex flex-col w-full h-full"
    >
      <div tw="flex items-center justify-between w-full pb-4">
        <div
          style={{ fontSize: width * 0.022, letterSpacing: "0.14em" }}
          tw="flex text-white font-extrabold"
        >
          {siteTitle.toUpperCase()}
        </div>
        <div
          style={{
            fontSize: width * 0.02,
            letterSpacing: "0.14em",
            padding: `${width * 0.008}px ${width * 0.016}px`,
          }}
          tw="flex bg-white text-black font-extrabold"
        >
          BLOG
        </div>
      </div>
      <div style={{ height: 4 }} tw="flex w-full bg-white" />

      <div tw="flex flex-col justify-end flex-grow py-6">
        {lines.map((line, i) => {
          // Only the quoted word earns the inverted bar; a bar on every
          // other line turns emphasis into wallpaper.
          const inverted = line.includes('"');
          return (
            <div
              key={line}
              style={{
                fontSize,
                marginTop: i === 0 ? 0 : gap,
                lineHeight: 0.85,
                letterSpacing: "-0.02em",
                backgroundColor: inverted ? "#ffffff" : "#0A0A0A",
                color: inverted ? "#0A0A0A" : "#ffffff",
                padding: inverted ? `0 ${Math.round(pad * 0.3)}px` : "0",
              }}
              tw="flex self-start font-extrabold"
            >
              {line}
            </div>
          );
        })}
      </div>

      <div style={{ height: 4 }} tw="flex w-full bg-white" />
      <div
        style={{ fontSize: width * 0.02, letterSpacing: "0.14em" }}
        tw="flex items-center justify-between w-full pt-4 text-white font-extrabold"
      >
        <div tw="flex">{`${width} X ${height}`}</div>
        <div tw="flex">{label.toUpperCase()}</div>
      </div>
    </div>
  );
};
```

The `targetLines` calculation is doing more than it looks like, because it's the reason the same template works at all three sizes. The wide OG card groups the title into two or three long lines, the square and portrait cards break it into four short ones, and the stack always sits flush against the footer with the leftover space above it. Nothing about a specific canvas is hardcoded.

Those two plain divs with `height: 4` are standing in for border rules, because a real CSS border took the whole process down when we tried it. There's a stack trace waiting for you further down, in the section on what's bitten us.

The `GET` handler needs two lines changed to thread the dimensions through.

```tsx
export async function GET({ url }: Request): Promise<ImageResponse> {
  const { searchParams } = new URL(url);
  const type = searchParams.get("type") as keyof typeof block;
  const { width, height } = getOgMetaData(searchParams); // now ratio-aware
  const para = Object.fromEntries(searchParams.entries());
  const options = await getOptions({ width, height });
  const image = block[type] ?? getGenericPageContent;
  try {
    const content = await image({ ...para, width, height });
    return new ImageResponse(content ?? errorContent, options);
  } catch (_err) {
    return new ImageResponse(errorContent, options);
  }
}
```

The Sanity fetch layer doesn't need touching at all, because the data doesn't care what shape the canvas is. `og-data.ts` still pulls SEO data through `sanityFetch` inside `"use cache"`, and the sync-tag webhook still revalidates it.

## What it renders

Here's what comes out of the code above at each of the three sizes, rasterized at 2x so the type stays sharp on retina screens. On the resvg side that's `fitTo: { mode: "width", value: width * 2 }`, and if you're using `ImageResponse` you get the same effect by doubling the option dimensions.

![The brutalist OG card at 1200x630: the title stacked in three lines of extra-bold uppercase, the quoted word on an inline highlight, dimensions stamped in the footer](https://qxvqs298ldvynvwd.public.blob.vercel-storage.com/blog/og-ratios-demo-brutal-v5-og-1200x630.png)

![The same card at 1080x1080: four stacked lines packed against the footer, the quoted word on its inline highlight](https://qxvqs298ldvynvwd.public.blob.vercel-storage.com/blog/og-ratios-demo-brutal-v5-square-1080x1080.png)

![The same card at 1080x1350: the four lines packed bottom-up on the portrait canvas, footer reading 1080 x 1350 portrait 4:5](https://qxvqs298ldvynvwd.public.blob.vercel-storage.com/blog/og-ratios-demo-brutal-v5-portrait-1080x1350.png)

You can see the wide one grouped the title into longer lines than the tall two, because `targetLines` aims for fewer, longer lines on a wide canvas. And I think the footer stamp is the best bit of the card, because once you've got several exports of one post sitting in a folder, the file tells you what it is before you upload it anywhere.

> **turbo-start-sanity**: The Next.js and Sanity starter this code is written against. Page builder, typed GROQ, live preview, and the OG route from this post, free and open source. [Get the template](https://www.sanity.io/templates/turbo-start-sanity)

## What it costs

We benchmarked both halves, so the raw Satori and resvg pipeline locally, and this site's production OG route over the network, which runs the same architecture and the same `ImageResponse`.

| Measurement | Cold | Warm |
| --- | --- | --- |
| Satori layout (per ratio) | 33ms | 2ms |
| resvg rasterization at 1x (per ratio) | 901ms | 54 to 56ms |
| resvg rasterization at 2x (per ratio) | | 124 to 126ms |
| Production route, fresh render with remote image fetch | 1.9s | 1.1s |
| Production route, CDN-cached repeat | | ~100ms |
| PNG output size (2x) | | 77 to 149KB |

Drawing the pixels is cheap. Layout is single-digit milliseconds once the fonts are loaded, and a warm 1x rasterize costs about 55ms. The expensive bit of a fresh render is fetching fonts and remote images, and that's why a fresh production render that pulls a hero image off storage takes about a second. So the cache header is the only setting here I'd bother arguing about. Our route ships `Cache-Control: public, max-age=31536000, immutable`, so any given image renders roughly once, ever, and every crawler after that gets the CDN copy in about 100ms.

## Everything that's bitten us

We've been running this setup in production for years now, and it's bitten us a fair few times. Roughly in the order it happened.

**Satori only does flexbox.** No grid, no float. The `tw` prop is a Tailwind-flavored shorthand rather than actual Tailwind, and colors have to be hex or rgb, because oklch and hsla render wrong without erroring. Our design tokens are oklch, so we convert at the template boundary.

**Every font weight is a separate fetch.** Satori inherits nothing from the system. The Google Fonts trick in the template works, where you request the CSS with a `Firefox/1.0` user agent to force a non-variable TTF, regex out the URL, and fetch the binary, but it runs on every uncached render. For fonts you control, I'd just vendor the files and read them from disk.

**Allowlist your image hosts.** If the route accepts an `image` query param and passes it to an `<img>` in the template, you've built a free proxy that'll fetch any URL on your infrastructure. Ours checks the hostname against an allowlist of exactly two hosts before Satori is allowed to fetch anything.

**Only ever emit one og:image.** It's tempting to put all three ratios in the metadata and let the platforms choose, but they'll all pick different ones, and your LinkedIn preview ends up being the square one cropped to landscape. We emit the single `1200x630` image in the page metadata, and the square and portrait URLs are just there for humans and tooling to fetch when they need that specific asset.

**A CSS border can crash the rasterizer.** The first version of this card used `borderBottom` with a solid white line for the header rule. Satori renders borders as path arcs, and with no border radius those arcs come out zero-radius, which panics resvg 2.6.2 outright, a Rust `unwrap()` on `None` in geom.rs that takes the whole process down. That's why the template above uses flat divs for the rules. If your route ever dies with a rasterizer panic instead of an error, diff the SVG for zero-radius arcs.

**There's no text stroke.** We tried outlined type for the alternating lines first, but Satori doesn't support `-webkit-text-stroke` and doesn't error either, it just emitted a 332-byte SVG with the text gone. The inverted white bars in the final design started life as that fallback, and I think they ended up better than the outline would have been.

**iMessage was the one that really got us.** Our site-wide default OG image is an animated GIF, which turned out to mean Apple's LinkPresentation framework shows only its first frame. The fix that survived testing was shipping an `og:video` mp4 twin alongside the static image, and we only found any of this by testing on an actual phone, because none of the validator tools caught it.

## Peeling the AI label off your AI slop

Hilton Lee's [guide on the Sanity Exchange](https://www.sanity.io/guides/hiltonlee981) covers a failure mode I hadn't thought about at all. Your images live in Sanity, they render fine on the site, and then someone downloads one to post natively on Instagram and the platform slaps an AI label on it, because the platform isn't looking at the pixels, it's reading the metadata that's still sitting inside the file.

AI tooling signs everything it makes. OpenAI's image models embed C2PA provenance manifests, Google's embed SynthID plus IPTC metadata, and Photoshop writes Content Credentials the moment Generative Fill touches a layer. Even Lightroom's AI Denoise gets flagged in some pipelines, which feels a bit much. And Sanity's image CDN only transforms pixels, so width and format and quality and focal point, it has no opinion about file-level provenance, which means the metadata rides along through your whole stack and announces itself at any platform that reads it. LinkedIn already renders C2PA as a visible credential on posts.

We are, to be clear, exactly the audience for this warning. Every hero image on this blog is a gpt-image-2 render of a Victorian cat, and my LinkedIn headshot, cheeky smile and all, was shot in the office and then hi-key edited with nano banana. I'd rather the file didn't go around announcing that, but announcing it is basically the entire point of the metadata.

I'd adopt Hilton Lee's workflow wholesale here. Keep the high-quality master in Sanity with its provenance intact, because provenance in your archive is a feature. When an image is headed for a native social upload, export it, check what it's carrying, and strip the C2PA and XMP blocks locally with a browser-based tool like [removeailabel.com](https://removeailabel.com), so the file never leaves your machine. Upload the cleaned copy and keep the master. Then write the process down in the Studio where your editors will actually see it, because the person doing the Instagram post is probably not the person who read this blog.

Whether provenance labels are good for the ecosystem is a separate argument, and I think they probably are. But an AI badge appearing on a client's brand account because nobody checked the file first isn't part of that argument, that's just a missing checklist step.

> **Sanity development**: We build Sanity studios and the pipelines around them, image generation included. Sanity Pioneer, first-cohort Ambassador, and about a decade of scar tissue. [See how we work with Sanity](/services/sanity)

## When to render and when to pre-generate

The route above renders at request time, and for link unfurls I think that's the right default, because crawlers hit URLs you can't predict, the immutable cache means each image only renders once, and there's no publish-time step to forget.

For images humans re-upload, so the Instagram export, the newsletter header, the scheduling tool asset, we pre-generate at publish time and store the files instead. This site's pipeline writes three variants to storage for every post the moment it's created, and the post just references them as plain URLs. A stored file has a stable URL, survives a framework migration, and goes through the metadata-stripping step above exactly once instead of on every download.

Screenshotting your own pages with a headless browser is the third option, and I wouldn't bother, because a template only changes when you change it, whereas a screenshot redraws your social cards every time anyone touches the page it points at.

The multi-ratio pattern lives in this post rather than the template for now, and if enough people lift it we'll PR it into [turbo-start-sanity](https://github.com/robotostudio/turbo-start-sanity) properly. If you're starting from zero, the 2023 post covers the [single-ratio setup](/blog/dynamic-open-graph-with-sanity-and-next-js), and the template ships the working route today.

## Frequently asked questions

### Can next/og generate images at any size?

Yes. ImageResponse takes arbitrary width and height options, and Satori lays out whatever dimensions you pass. The practical constraints are render time and file size, not the API: our 1080x1350 render is 31KB of PNG and takes about 55ms to rasterize once the renderer is warm, and we have rendered up to 1080x1350 without getting anywhere near function limits.

### What size should Open Graph and social images be in 2026?

1200x630 remains the standard for link unfurls on every platform (X, LinkedIn, Slack, iMessage, WhatsApp). For images you post directly rather than unfurl, use 1080x1080 for square feed posts and 1080x1350 for portrait, which is the largest canvas Instagram gives you in feed. Stories and TikTok want 1080x1920. One template can render all of them.

### Should OG images be generated at request time or pre-generated?

Both, for different jobs. Link unfurl images should render at request time behind an immutable cache header, because crawlers fetch them sporadically and the CDN absorbs repeats: our production route serves cached hits in about 100ms. Images a human will download and re-upload to Instagram or a scheduling tool are better pre-generated to storage at publish time, because you want a stable file, not an endpoint.

### How fast is next/og image generation?

Measured on this site's production route: 1.1 to 1.9 seconds for a fresh render that fetches a remote hero image, and about 100ms for a CDN-cached repeat. Locally, Satori layout takes 2 to 33ms and resvg rasterization about 55ms warm (the first render pays around 900ms warming up). The slow part of a fresh render is fetching remote assets, not drawing pixels.

### Does Satori support Tailwind grid or oklch colors?

No to both. Satori implements a flexbox-only subset of CSS: no grid, no float, and the tw prop covers a Tailwind-flavored shorthand rather than the full framework. Colors need to be hex or rgb; oklch and hsla silently render wrong. Fonts are not inherited from anywhere, so every weight you use must be loaded explicitly as font data.

### How many og:image tags should a page emit?

One. We tested multi-image tags on this site and the failure mode is that every platform picks its own favorite, so your preview becomes a lottery. Emit the single 1200x630 image in metadata and treat the other aspect ratios as endpoints you call when you need the asset itself.

### Why does my Open Graph image not show up in iMessage?

The usual causes are a GIF (Apple's LinkPresentation framework renders only the first frame, so an animated OG image freezes) or an image URL that redirects or requires cookies. Our fix for the GIF case was emitting an og:video mp4 twin alongside the static image. Test with a real device, because the iMessage preview pipeline behaves like none of the validator tools.

### Do AI-generated images contain metadata that platforms can detect?

Yes. OpenAI's image models embed C2PA provenance manifests, Google's embed SynthID watermarks plus IPTC fields, and Adobe tools write Content Credentials on export. LinkedIn already surfaces C2PA as a label on posts, and Instagram has flagged uploads based on file metadata. Sanity's image pipeline transforms pixels, not provenance, so a file that looks clean on your site can still announce itself when re-uploaded.

### How do I remove the AI label from an image before posting it to Instagram?

The label comes from C2PA and XMP metadata inside the file, not from how the image looks, so removing it means stripping that metadata. Export the image, then run it through a local browser-based stripper like removeailabel.com (the file never leaves your machine) and upload the cleaned copy. Keep the original with its provenance intact in your CMS or archive; strip only the copy headed for the platform.

## Related posts

- [Working with Turbo Start Sanity](/blog/working-with-turbo-start-sanity)
- [Dynamic open graph images with Sanity & Next.js](/blog/dynamic-open-graph-with-sanity-and-next-js)
- [Our App Router setup with Next 13.4, Tailwind CSS and Sanity](/blog/a-seamless-experience-with-next-134-tailwind-css-and-sanity)