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 social crops. The 2023 walkthrough of the basics is still up and still correct, but it stops at one size, 1200x630, because in 2023 that was the whole job. It no longer is. A link unfurl wants 1200x630, an Instagram feed post wants 1080x1350, a square crop wants 1080x1080, and designing each of those by hand for every post is work a template should absorb.

So this post does the 2026 version: one Satori template, three aspect ratios, rendered from the same route. The code is written against turbo-start-sanity, our open-source Next.js and Sanity starter, so you can lift it straight into a project bootstrapped from the template. We ran the renders and benchmarks for real; the images and numbers below are the actual output.

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 if the editor uploaded a dedicated share image it serves that; otherwise it falls back to a generated card: dark background, site title top left, a type pill for blog posts, and the page title in 76px Inter pinned to the bottom.

The bit most people miss is in og-config.ts. 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 poke it without cloning anything. The problem is that raw width and height params give you a 76px headline floating in a 1080px square with 70px of padding designed for a landscape card. The actual work is making one template hold up at every size.

Name the ratios, stack the type

First, replace free-form dimensions with named presets. Free-form params mean anyone with the URL can request a 4000x4000 render on your compute; presets mean the route renders exactly three things, and each one knows its own label:

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. Instead of scaling one landscape layout up and down, this card treats the title as the design: uppercase lines packed tight and anchored to the bottom of the canvas, the quoted word on an inline white highlight that hugs the text, and the geometry stamped in the footer so each render documents its own aspect ratio. Two numbers do all the layout work. Dividing the inner width by a line's character count sizes that line to span the canvas. The 0.72 in that sum is the average advance width of Inter ExtraBold uppercase as a fraction of font size, measured against real renders after 0.6 clipped. The second number is a packed-height fit, which keeps the stack from shoving the footer off the squatter canvases. Every line then shares one size and one gap of 0.15em, so the rhythm is identical 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 the part that makes one template hold three ratios: 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 sits flush against the footer, leftover space above it. Nothing about a specific canvas is hardcoded.

Those two plain divs with height: 4 stand in for border rules because a real border crashes the rasterizer. The scars section has the stack trace.

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);
  }
}

That's the whole change. The Sanity fetch layer (og-data.ts, which pulls SEO data through sanityFetch inside "use cache" so the sync-tag webhook can revalidate it) doesn't change at all, because the data doesn't care what shape the canvas is.

What it renders

The same template, called three times with three ratio params, rasterized at 2x so the type stays sharp on retina screens (fitTo: { mode: "width", value: width * 2 } on the resvg side; ImageResponse users get the same effect by doubling the option dimensions). These are real renders from the code above, not mockups:

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

Same code, three different posters, and the wide one grouped its own lines differently from the tall two because the ratio told it to. The footer stamp earns its place once you have several exports of one post in a folder, because the file states its own dimensions 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: the raw Satori and resvg pipeline locally, and this site's production OG route (same architecture, same ImageResponse) over the network.

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 pixels is cheap. Layout is single-digit milliseconds once the fonts are loaded, and rasterizing a 1x render costs about 55ms warm. The expensive parts of a fresh render are fetching fonts and remote images, which is why a fresh production render that pulls a hero image off storage takes a second. So the cache header is the only setting here worth 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 ~100ms CDN copy.

What broke on us

We've been running this architecture in production for years. These are the scars, in the order we earned them.

Satori is flexbox or nothing. No grid, no float. The tw prop is a Tailwind-flavored shorthand, not Tailwind, and colors must be hex or rgb: oklch and hsla render wrong without erroring. If your design tokens are oklch (ours are), convert at the template boundary.

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

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 fetches any URL on your infrastructure. Ours checks the hostname against an allowlist of exactly two hosts before Satori is allowed to fetch anything.

Never emit more than one og:image. It's tempting to put all three ratios in the metadata and let platforms choose. They will, and they'll choose differently, and your LinkedIn preview will be the square one cropped to landscape. One og:image at 1200x630 in the page metadata; the square and portrait URLs exist for humans and tooling to fetch when they need that specific asset.

CSS borders can crash your rasterizer. The first version of the brutalist card used borderBottom: "3px solid #ffffff" for the header rule. Satori renders borders as path arcs, and with no border radius those arcs come out zero-radius (A0,0 0 0 1 in the SVG), which panics resvg 2.6.2 outright: a Rust unwrap() on None in geom.rs, taking the whole process down. The fix is the two height: 4 divs in the template above. If your route dies with a rasterizer panic instead of an error, diff the SVG for zero-radius arcs.

Text stroke doesn't exist. We tried outlined type for the alternating lines first (-webkit-text-stroke, transparent fill). Satori doesn't support it and doesn't error either: it emitted a 332-byte SVG with the text gone. The inverted white bars in the final design started life as that fallback and ended up better than the outline would have been.

iMessage will humble you. 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 it. No validator tool caught this; a phone did.

Peeling the AI label off your AI slop

Hilton Lee's guide on the Sanity Exchange covers a failure mode we'd never thought about: 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 a label on it. The platform read the file's metadata, not the pixels.

AI tooling signs its work. OpenAI's image models embed C2PA provenance manifests. Google's embed SynthID plus IPTC metadata. Photoshop writes Content Credentials the moment Generative Fill touches a layer, and even Lightroom's AI Denoise gets flagged in some pipelines. Sanity's image CDN transforms pixels (width, format, quality, focal point) but has no opinion about file-level provenance, so the metadata rides along through your whole stack and announces itself on arrival 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 Jono's LinkedIn headshot, cheeky smile and all, was shot in the office and then hi-key edited with nano banana. He would prefer the file not mention that, and the file absolutely wants to mention that.

Adopt Hilton Lee's workflow wholesale. 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 campaign assets never leave your machine. Upload the cleaned file; keep the master. Then write the process down in the Studio where your editors will see it, because the person doing the Instagram post is not the person who read this blog.

Whether provenance labels are good for the ecosystem is a separate argument (they probably are). An AI badge appearing on a client's brand account because nobody checked the file before re-uploading it is not part of that argument; it's 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

Where each approach belongs

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

For images humans re-upload (the Instagram export, the newsletter header, the scheduling tool asset), we pre-generate at publish time and store the files. This site's pipeline writes three variants to storage for every post the moment it's created, and the MDX frontmatter references them as plain URLs. A stored file has a stable URL, survives a framework migration, and can go 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 the route above beats it on both speed and predictability. A template only changes when you change it. 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 is in this post rather than the template for now; if enough people lift it, we'll PR it into turbo-start-sanity properly. The 2023 post covers the single-ratio setup if you're starting from zero, 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.

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.