In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.
If you're running a markdown-based content pipeline in Next.js and want to auto-generate hero images for every article, this guide walks through a working CLI script: it reads frontmatter, builds a prompt from the article's title and subtitle, calls OpenAI's gpt-image-2, converts the result to JPEG with Sharp, and writes the final image plus a candidate-review folder to disk. The pattern also patches the article's frontmatter with the image path once you accept a candidate.
I built this for our own content pipeline at BuildWithMatija, where every article needs a hero image before it ships. Doing this by hand for 200+ articles wasn't sustainable, so the script became part of our pnpm content:image command. This guide documents the exact implementation so you can port it into your own repo.
The Problem
Generating one-off images with the OpenAI Images API is well documented. Wiring that into a content pipeline that already has markdown files, frontmatter, and a folder structure for images takes more decisions than the API docs cover: which model to use, what format to request, how to convert that format into what your site actually serves, how to let a human review candidates before one gets committed, and how to write the result back into the article's frontmatter. This guide covers all of it as one connected workflow.
Prerequisites
A Next.js (or similar) repo with markdown content and frontmatter (via gray-matter)
Node.js with tsx for running TypeScript scripts directly
As of July 2026, gpt-image-2 is OpenAI's current state-of-the-art image model. gpt-image-1.5 is still available if you need it, but new pipelines should default to gpt-image-2. Use the moving alias gpt-image-2 if you want to automatically receive model updates over time, or pin to a dated snapshot like gpt-image-2-2026-04-21 if reproducibility matters more than staying current.
This pipeline is a one-prompt, one-image generation task, so the Images API (openai.images.generate()) is the right interface. Reach for the Responses API's image-generation tool only if your use case needs conversational generation, multi-turn editing, or image inputs carried across turns — none of which applies here.
Step 1: The OpenAI Wrapper
Put the actual API call in one shared module so both the interactive CLI and any automated push script can reuse it.
File: src/lib/images/openai-generate.ts
ts
importOpenAIfrom'openai'/**
* Generate an image using OpenAI's image API.
* Requests WebP output and returns it as a Buffer decoded from base64.
*/exportasyncfunctiongenerateImageBuffer(openai: OpenAI,
prompt: string,
): Promise<Buffer> {
const response = await openai.images.generate({
model: 'gpt-image-2',
prompt,
size: '1536x1024',
quality: 'medium',
n: 1,
output_format: 'webp',
background: 'opaque',
})
const imageData = response.data?.[0]?.b64_jsonif (!imageData) {
thrownewError('OpenAI did not return image data.')
}
returnBuffer.from(imageData, 'base64')
}
A few parameters here matter more than they look. size: '1536x1024' gives a landscape hero shape; gpt-image-2 supports flexible dimensions, but this keeps the existing hero-image contract intact. quality: 'medium' is a deliberate cost-to-quality tradeoff for images that will be resized anyway. output_format: 'webp' requests WebP because Sharp handles the conversion to JPEG in the next step. background: 'opaque' matters because gpt-image-2 doesn't support transparent backgrounds — use 'opaque' or 'auto', and don't attempt 'transparent' with this model. Finally, the response comes back as b64_json, not a URL, so you decode it directly into a Buffer rather than fetching anything.
fit: 'cover' keeps the crop consistent even if the model returns something slightly off the requested aspect ratio. JPEG quality 82 is the tested balance between file size and visible artifacts for hero images displayed at typical blog widths.
Step 3: Managing the Images Folder
The folder contract keeps final images separate from in-progress candidates:
code
content/
images/
<slug>.jpg # FINAL accepted hero
<slug>/
candidate-1.jpg
candidate-2.jpg
candidates.json # audit log of prompts used
draft/my-post.md
The prompt follows a fixed pattern so every hero image stays visually consistent across the site:
code
BLOG ARTICLE. Minimalistic design. Limit to maximum two illustrations per image.
Concept: <subtitle || title || humanized slug>.
Include logo. Do not include any text whatsoever.
Use a <light|dark> solid background color (<hex>). Keep foreground high-contrast.
The concept line resolves in priority order: subtitle, then title, then the humanized slug as a last resort. An optional --styling flag can inject extra style text right after BLOG ARTICLE. while leaving the concept and background logic untouched.
Background color comes from a fixed palette of named light/dark hex pairs (Navy, Ocean, Cobalt, Indigo, and a handful more). You have two ways to pick from that palette. A random selection picks a random pair and a random light-or-dark scheme on every run, which is the default. A deterministic selection hashes the slug and derives both the pair index and the scheme from that hash, giving the same article the same colors every time it's regenerated:
Pass randomGradient: false to buildArticleImagePrompt when you want that per-article consistency instead of the random default.
Step 5: The Interactive CLI
The full script accepts a markdown file path and a handful of flags:
bash
tsx scripts/generate-hero-image.ts <path-to.md>
[--prompt "full override"]
[--styling "visual style only"]
[--accept] # auto-accept first candidate, skip the review prompt
[--open] # open candidate in system viewer
The flow it runs:
Confirm OPENAI_API_KEY is set.
Read the markdown file with gray-matter and pull slug, title, subtitle, and tools from frontmatter, falling back to the file's basename if slug is missing.
Build the prompt, using --prompt verbatim if provided, otherwise running it through buildArticleImagePrompt.
Ensure the candidate directory exists for that slug.
Loop: generate a candidate, convert it with Sharp, write candidate-N.jpg, and append an entry to candidates.json. If --accept was passed, copy the first candidate straight to the final path and exit. Otherwise, prompt for Accept, Retry, Override prompt, or Quit.
On accept, patch the article's frontmatter with the final image path and the prompt that produced it.
Frontmatter patching uses gray-matter's stringify so the YAML block updates cleanly without touching the rest of the file:
Use path.posix.join here specifically, since a plain path.join on Windows would write backslashes into the YAML frontmatter and break the path on deploy.
Step 6: The Non-Interactive Fallback
If you also have an automated "push markdown" script that publishes articles without a human reviewing every image, give it a simpler path that skips the candidate folder entirely and writes straight to the final file when one doesn't already exist:
You should end up with content/images/test-hero-image/candidate-1.jpg, a candidates.json audit log next to it, a final content/images/test-hero-image.jpg, and updated frontmatter containing articleImage and imagePromptOverride. Drop --accept and you'll get the interactive Accept/Retry/Override/Quit prompt instead.
API Options at a Glance
Param
Value
Why it's set this way
model
gpt-image-2
Current state-of-the-art image model as of July 2026
size
1536x1024
Matches the existing landscape hero-image contract
quality
medium
Cost-to-quality tradeoff for images that get resized anyway
n
1
One candidate per call
output_format
webp
Sharp converts this to JPEG for the site
background
opaque
gpt-image-2 doesn't support transparent backgrounds
Response field
b64_json
Base64-encoded, decoded directly — not a URL to fetch
Common Mistakes to Avoid
Don't try to fetch a URL from the response or add response_format — gpt-image-2 returns base64 data in b64_json, full stop. Don't skip the Sharp conversion step, since the API gives you WebP and most sites are still serving JPEG. Don't reach for the legacy DALL·E 2 or 3 models here; gpt-image-2 with output_format and background set explicitly is the current path. Always write the final accepted image to content/images/<slug>.jpg, not only inside the candidate folder. And let candidate numbering pick up from existing files in the directory so a retry doesn't clobber a previous attempt.
FAQ
Why does the API request WebP and then convert it to JPEG?gpt-image-2 returns WebP through output_format. Converting with Sharp right after generation keeps the final asset in the format most CDNs and <img> fallbacks expect, while still getting WebP's smaller payload for the initial transfer.
Can I get a transparent background from gpt-image-2?
No. gpt-image-2 doesn't support transparent backgrounds. Set background to 'opaque' or 'auto' and design your prompt around a solid background color instead.
How do I keep the same hero image style across regenerations of the same article?
Pass randomGradient: false to buildArticleImagePrompt. That switches the palette selection from random to a hash of the slug, so the same article always resolves to the same background pair and light/dark scheme.
What happens if I run the script again on an article that already has a hero image?
The interactive CLI still generates new candidates into that article's candidate folder and won't touch the existing final image until you explicitly accept one. The non-interactive fallback used for automated publishing checks for an existing final file first and skips generation entirely if one is already there.
Do I need the Responses API for this instead of the Images API?
Not for this workflow. The Images API's images.generate() covers one-prompt, one-image generation cleanly. Reach for the Responses API's image tool only if you need multi-turn editing or conversational generation with image inputs carried across steps.
Wrapping Up
This pipeline turns hero-image generation from a manual, per-article task into a one-command step in the publishing flow: markdown frontmatter goes in, a prompt gets built from the article's own metadata, gpt-image-2 generates a candidate, Sharp converts it to the format the site serves, and an accepted candidate writes its own path back into the frontmatter. The candidate-review folder means nothing gets locked in without a look first, and the non-interactive fallback covers automated publishing runs where no one's watching.
Let me know in the comments if you have questions, and subscribe for more practical development guides.