Shipping Heroes Without a Designer
Every post needs a hero image and I'm not an illustrator. How a script rendered decent heroes from HTML — and why that's a placeholder, not the finish line.

Every post on this blog needs a hero image, and I am not an illustrator. That's a real constraint, not a humble aside: a wall of text with no art looks unfinished, and stock photos of glowing keyboards look worse. So I did what the rest of the build does — turned the problem into code — and got heroes that are decent, consistent, and honestly a little too consistent. This is the image pipeline, and why I treat it as a placeholder. Part of the Building with AI series.
Heroes as rendered artifacts
The trick is to stop thinking of a hero as a file you find and start thinking of it as output you render. Each post gets an HTML/SVG scene — gradients, a motif, some depth — and a headless browser screenshots it at the exact hero dimensions. Deterministic, versioned, and free.
1import { chromium } from "playwright";
2
3export async function renderHero(html: string, out: string) {
4 const browser = await chromium.launch();
5 const page = await browser.newPage({
6 viewport: { width: 2400, height: 1260 }, // hero spec, 16:8.4-ish
7 deviceScaleFactor: 1,
8 });
9 await page.setContent(html, { waitUntil: "networkidle" });
10 await page.screenshot({ path: out, type: "png" });
11 await browser.close();
12}That highlighted viewport is doing quiet, important work. Sanity's asset pipeline only applies hotspot and crop to raster formats, and the site crops heroes to different ratios across breakpoints, so rendering a real PNG at 2400×1260 — with the important content kept in the central 70% — is what keeps the art from getting clipped on a phone.
The rules the pipeline enforces
A few conventions are baked into the renderer because I learned them the hard way. No title text inside the image — the site prints the post title right next to the hero, so baked-in text reads as a broken duplicate and gets cropped. Raster only, never SVG heroes, for the hotspot reason above. And a safe central zone so aggressive card crops never eat the subject.
A hero is an illustration of the idea, not a caption. If it's repeating the title, it's doing the title's job badly.
Where this approach falls short
Here's the honest part. Because every hero is rendered by the same script, every hero shares the same flat, diagrammatic style. Open five posts and you see five variations of one template. It reads fine in isolation and looks templated in aggregate — exactly the thing a growing blog can't afford, because the sameness quietly says "nobody art-directed this."
So I now treat the script-rendered hero as a functional placeholder, not the target. The goal is a distinct illustration per post — varied composition, palette, and motif — the way a real cover would be. Increasingly that means describing the idea to an image model and art-directing the result, then dropping the raster into the same slot the script used to fill. The pipeline's rules (size, no title text, central safe zone) still apply; only the source of the pixels changes.
The slot stays the same
What makes swapping painless is that nothing downstream cares how the PNG was made. The schema's imageWithAlt object and the required alt text from Designing the Content Model by Conversation don't change. A script-rendered hero and a hand-directed illustration import through the same field with the same constraints, so I can upgrade a post's art without touching a line of code.
Automate the boring 90% of a hero, but leave a clean seam to hand-craft the 10% that makes the blog feel authored. The script got every post over the line on day one; the illustrations are how it stops looking like a template. Next, another thing I refused to hand-maintain — SEO That Generates Itself.
