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



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



