Building with AI

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.

Val OvinnikovVal Ovinnikov3 min read
An empty ornate picture frame on an easel slowly filling with a glowing painted scene, brushes and code side by side in warm light.

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.

scripts/render-hero.ts
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.

3 min

Statically Rendered, Instantly Fresh

Static pages that update the moment I publish. How a Sanity webhook triggers on-demand revalidation so a fully static Next.js site never serves stale content.

building with ai
3 min

Styling at Speed: Tailwind v4 With an Agent

Tailwind v4 moved config into CSS, and that made an agent a far better collaborator. How design tokens in one @theme block kept the whole site consistent.

building with ai
2 min

Rendering Portable Text in Next.js 16

Portable Text is just JSON until you render it. How I mapped Sanity's block format to React components — headings, links, and real code blocks — in the App Router.

building with ai