← All notes

How I Generate Blog Cover Images in Next.js

I replaced one-off blog artwork with a deterministic cover system built from MDX metadata, ImageResponse, Lucide icons, and a small amount of validation.

8 min readBy Jansen Cadorna
  • nextjs
  • engineering
  • design
  • seo
Cover image for How I Generate Blog Cover Images in Next.js

I had a small problem with my blog that was starting to become annoying.

Every article needed a cover image.

I could find a stock photo, make something manually, or generate something with AI. All three worked, but none of them felt like a system. The visual style changed from post to post, adding a new article meant another design task, and the image itself usually said very little about the article.

So I replaced that workflow with something deterministic.

My portfolio now generates a 1200×630 cover from the article's own metadata: title, description, tags, series, and a few selected icons.

The result is not supposed to be a unique illustration every time. That is the point. I wanted the covers to feel like they belong to the same publication.

The article became the source of truth

My notes already live as MDX files in content/notes.

A post has frontmatter like this:

---
title: Adding IndexNow to My Next.js Portfolio
description: How I wired IndexNow into my portfolio without turning every deploy or page view into an indexing request.
date: 2026-09-05
tags:
  - seo
  - nextjs
  - indexnow
  - engineering
series: SEO / Engineering
coverIcons:
  - Search
  - Send
  - Code2
---

Instead of adding another coverImage URL, the renderer uses those fields to build the image.

That gives me one content model for the article and its cover. If the title changes, the cover changes. If the topic changes, the metadata changes with it.

I still support hand-picked cover images for older posts. The generated cover is only the fallback.

Conceptually, the resolution is simple:

const cover = post.coverImage ?? `/blog/${post.slug}/cover`

That one resolved value is then reused by the article page, Open Graph metadata, Twitter metadata, BlogPosting.image, and sitemap image metadata.

I did not want five parts of the site making five different decisions about what the article image should be.

The cover is a React component, but not a normal one

Next.js supports generating images with ImageResponse. The API lets you describe an image using JSX and a supported subset of CSS, then renders the result as an image. Next.js also supports programmatically generated Open Graph images through the same underlying approach.

The official Next.js documentation covers dynamic metadata and ImageResponse, while Vercel's OG tooling is built around Satori rendering HTML and CSS into images.

For my implementation, the cover template receives only the information it needs:

type BlogCoverTemplateProps = {
  title: string
  description: string
  tags: string[]
  series?: string
  coverIcons?: BlogCoverIcon[]
  profileImage: string | ArrayBuffer
}

The template then renders the same basic composition every time:

profile + identity
series
article title
article description
tags
 
                topic icons

I deliberately kept it constrained. A deterministic system becomes less useful if every article can override the entire design.

The article can choose its topic and icons. It cannot casually turn the cover into a completely different visual language.

I made the icons data, not arbitrary components

The covers use Lucide icons, but I did not let MDX frontmatter reference any component name it wants.

There is a strict allowlist:

export const BLOG_COVER_ICONS = [
  "Bot",
  "BrainCircuit",
  "Code2",
  "Globe2",
  "Search",
  "Send",
  "ShieldCheck",
  "Database",
  "Rocket",
  "Palette",
  "Layers3",
  "Cloud",
  "BookOpen",
  // ...
] as const

That gives the frontmatter a real TypeScript type and gives the parser something concrete to validate.

A post can specify between one and three supported icons. If it contains an invalid value, parsing fails with the post slug and the supported icon list.

I prefer that over silently dropping an icon and discovering a broken-looking cover after deployment.

There was another complication: rendering Lucide React components directly inside the image pipeline was not the cleanest boundary for this setup.

I ended up generating static SVG data URIs from Lucide's icon-node data. The cover renderer consumes those assets instead of depending on the normal client-facing React component path.

It is a little more plumbing, but the boundary is much clearer: the MDX stores a validated icon key, and the renderer receives deterministic image data.

Legacy posts infer their own visual

I did not want to edit every existing article just to make the new system work.

If coverIcons is missing, the blog infers icons from tags.

For example:

seo / indexnow       -> Search, Globe2, Send
ai / llm             -> Bot, BrainCircuit
security             -> ShieldCheck, LockKeyhole
nextjs / typescript  -> Code2, Braces
design / uiux        -> Palette, PenTool, Layers3
cloud / devops       -> Cloud, Server

The inference is intentionally boring. It is a fixed set of rules, not an AI call.

The same applies to the series label. An explicit series wins; otherwise the first useful tag becomes a readable label. structured-data, for example, becomes Structured Data.

This means an old article with valid tags can immediately get a reasonable cover without changing its file.

The profile image caused a more interesting problem

My canonical profile image is a WebP file.

That is fine for the website, but the image renderer did not accept it in the way I needed for this route. I also did not want the cover endpoint to make a network request back to my own production domain just to fetch my avatar.

So the route reads the profile image directly from public/, converts it to a small PNG buffer with sharp, and passes that buffer into the cover template.

The flow becomes:

public/profile.webp

read locally

sharp -> PNG buffer

ImageResponse

No self-fetch. No remote dependency. The site's existing profile configuration remains the source of truth.

Then next/image broke the generated cover

The first version rendered the cover route correctly on its own.

The article page still failed.

I was passing the generated URL into Next.js <Image>, which transformed it into an optimization request resembling:

/_next/image?url=/blog/<slug>/cover...

That path returned INVALID_IMAGE_OPTIMIZE_REQUEST.

The generated cover was already a final 1200×630 PNG. Sending it through another image transformation layer was unnecessary anyway.

So I split the behavior:

  • custom/static cover images continue using next/image
  • generated blog covers use a normal <img> with explicit dimensions and the correct aspect ratio

That removed the failing optimizer path entirely.

This is one of those fixes that looks less sophisticated but is actually a cleaner model. The generated endpoint already owns the image transformation. I do not need another system transforming the result again.

I generate published cover routes at build time

Every published post has a stable route:

/blog/<slug>/cover

The route uses the published article list to generate static parameters, and unknown or unpublished slugs return 404.

That matters because these images are not just decoration inside the page. Crawlers and social platforms may request them independently through metadata.

Vercel's OG tooling is designed around generated social images that can be cached, and I give these generated responses a long shared cache lifetime as well.

The URL is predictable, the output is deterministic, and the article does not need to know where some uploaded asset happens to live.

One cover, everywhere

The part I like most is not actually the renderer.

It is that the cover became infrastructure instead of an attachment.

When I publish an article now, the same resolved image flows into:

article page
Open Graph
Twitter card
BlogPosting JSON-LD
sitemap image metadata

That fits the same direction I took when making my website machine-readable: define one canonical representation, then make the rest of the site reference it.

It also pairs naturally with the IndexNow integration. Publishing a note is becoming a more complete pipeline: the content, metadata, cover, sitemap entry, and search-engine notification all come from systems that already understand the article.

Why I prefer this over generating artwork for every post

There is nothing wrong with custom artwork. Some articles deserve it.

But most technical notes do not need a miniature art direction project before I can publish them.

The generated system gives me three things I care about more:

Consistency. Every generated cover clearly belongs to the same site.

Speed. Publishing a note does not create another manual design task.

Structure. The image comes from metadata I already need to maintain correctly.

It is also deliberately replaceable. If a post eventually needs a real photograph or custom illustration, coverImage still overrides the generated version.

That is the balance I wanted: automation as the default, not as a restriction.

The interesting part was not learning how to draw text onto a PNG. It was deciding which data should control the image, validating that data early, and making one generated asset usable everywhere the article needs to exist.