Skip to content




Never "design" an OG again

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.


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 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, 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 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

The same card at 1080x1080: four stacked lines packed against the footer, the quoted word on its inline highlight

The same card at 1080x1350: the four lines packed bottom-up on the portrait canvas, footer reading 1080 x 1350 portrait 4:5

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.

Services
$ 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

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.

MeasurementColdWarm
Satori layout (per ratio)33ms2ms
resvg rasterization at 1x (per ratio)901ms54 to 56ms
resvg rasterization at 2x (per ratio)124 to 126ms
Production route, fresh render with remote image fetch1.9s1.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 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, 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.

Services
$ 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

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.

No spam, only good stuff

Get the next one

Only god knows why anybody would purposefully subscribe themselves to a newsletter that moans about development. These poor souls did though
Profile 1
Profile 2
Profile 3
Profile 4
Profile 5

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 properly. If you're starting from zero, the 2023 post covers the single-ratio setup, and the template ships the working route today.

Frequently asked questions

About the authors

Jono Alford

Founder of Roboto Studio, specializing in headless CMS implementations with Sanity and Next.js. A Sanity Pioneer and first-cohort Sanity Community Ambassador, focused on editorial experiences that help teams ship faster.

Sameer Singh
Sameer Singh

Design Engineer

Design Engineer bridging the gap between design and code. Turns pixel-perfect concepts into polished, interactive experiences with a keen eye for detail and motion.

Tope Akintola
Tope Akintola

Frontend Developer

Frontend Developer with a sharp eye for interaction design and component architecture. Brings ideas to life in the browser with a focus on speed, polish, and maintainability.



Related posts





Get in touch

Tell us what you're building. We reply within one working day. Jono or someone on the team picks up every message personally.

By sending this you agree to our privacy policy. We only use your details to reply.