BuildWithMatija
  1. Home
  2. Blog
  3. Next.js
  4. Generate Hero Images with gpt-image-2 — Next.js Pipeline

Generate Hero Images with gpt-image-2 — Next.js Pipeline

Step-by-step CLI guide to auto-generate, convert, and patch hero images using OpenAI gpt-image-2, Sharp, and Next.js

11th July 2026·Updated on:21st July 2026··
Next.js
Generate Hero Images with gpt-image-2 — Next.js Pipeline

⚡ Next.js Implementation Guides

In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.

No spam. Unsubscribe anytime.

📄View markdown version
0

Frequently Asked Questions

About the author

Matija Žiberna

Matija Žiberna

Full-stack developer, co-founder

AboutResume

Self-taught full-stack developer sharing lessons from building software and startups.

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

Contents

  • The Problem
  • Prerequisites
  • Choosing the Model and API
  • Step 1: The OpenAI Wrapper
  • Step 2: Converting WebP to JPEG with Sharp
  • Step 3: Managing the Images Folder
  • Step 4: Building the Prompt from Frontmatter
  • Step 5: The Interactive CLI
  • Step 6: The Non-Interactive Fallback
  • Testing the Full Pipeline
  • API Options at a Glance
  • Common Mistakes to Avoid
  • FAQ
  • Wrapping Up
On this page:
  • The Problem
  • Prerequisites
  • Choosing the Model and API
  • Step 1: The OpenAI Wrapper
  • Step 2: Converting WebP to JPEG with Sharp
Build with Matija logo

Build with Matija

Senior-led B2B websites, applications, content systems, and digital infrastructure. Business-first, full-stack, AI-assisted, no handoffs.

Services

  • B2B Website Development
  • Headless CMS
  • AI Integration & Implementation
  • Next.js + Payload Advisory
  • Digital Platform Discovery
  • Website Growth Review

Resources

  • Case Studies
  • How I Work
  • Blog
  • Topics
  • CMS Hub
  • E-commerce Hub
  • B2B Website Strategy
  • Dashboard

Headless CMS

  • Payload CMS Developer
  • CMS Migration
  • Multi-Tenant CMS
  • Payload vs Sanity
  • Payload vs WordPress
  • Payload vs Contentful

Discuss the system

Planning a rebuild, migration, application, or workflow change? Start with the business problem and the system behind it.

Book a discovery callContact me →
© 2026Build with Matija•All rights reserved•Privacy Policy•Terms of Service
BuildWithMatija
Get In Touch

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
  • An OPENAI_API_KEY

Install the dependencies:

bash
pnpm add openai@^6.46.0 sharp gray-matter dotenv
pnpm add -D tsx typescript @types/node

These versions are confirmed working as of July 2026: openai ^6.46.0 is the current SDK release, sharp ^0.34, gray-matter ^4, dotenv ^17, and tsx ^4.

Add your key to .env:

bash
OPENAI_API_KEY=sk-...

And load it at the top of the script with import 'dotenv/config'.

Register the script in package.json:

json
"content:image": "tsx scripts/generate-hero-image.ts"

Choosing the Model and API

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
import OpenAI from 'openai'

/**
 * Generate an image using OpenAI's image API.
 * Requests WebP output and returns it as a Buffer decoded from base64.
 */
export async function generateImageBuffer(
  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_json

  if (!imageData) {
    throw new Error('OpenAI did not return image data.')
  }

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

Instantiate the client once and pass it in:

ts
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! })

Step 2: Converting WebP to JPEG with Sharp

The API returns WebP. Most sites want JPEG on disk, so this pipeline always converts before writing anything:

ts
import sharp from 'sharp'

const webpBuffer = await generateImageBuffer(openai, prompt)
const jpegBuffer = await sharp(webpBuffer)
  .resize({ width: 1536, height: 1024, fit: 'cover' })
  .jpeg({ quality: 82 })
  .toBuffer()

fs.writeFileSync(outPath, jpegBuffer)

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

Ensure the base directory exists:

ts
// File: scripts/lib/image-style.ts
import fs from 'node:fs'
import path from 'node:path'

export function ensureImagesDir(): string {
  const imagesDir = path.join(process.cwd(), 'content', 'images')
  if (!fs.existsSync(imagesDir)) {
    fs.mkdirSync(imagesDir, { recursive: true })
  }
  return imagesDir
}

export function humanizeSlug(slug: string): string {
  return slug.replace(/[-_]+/g, ' ').trim()
}

Each article gets its own candidate subdirectory:

ts
function ensureCandidateDir(slug: string): string {
  const imagesRoot = ensureImagesDir()
  const candidateDir = path.join(imagesRoot, slug)
  if (!fs.existsSync(candidateDir)) {
    fs.mkdirSync(candidateDir, { recursive: true })
  }
  return candidateDir
}

Candidate numbering has to continue from whatever already exists in that folder, or a retry will silently overwrite an earlier candidate:

ts
function detectNextCandidateIndex(candidateDir: string): number {
  const files = fs
    .readdirSync(candidateDir)
    .filter((file) => /^candidate-(\d+)\.jpg$/.test(file))

  if (files.length === 0) return 1

  const lastIndex = files
    .map((file) => parseInt(file.match(/^candidate-(\d+)\.jpg$/)?.[1] ?? '0', 10))
    .filter((v) => !Number.isNaN(v))
    .sort((a, b) => b - a)[0]

  return (lastIndex ?? 0) + 1
}

And accepting a candidate is a plain file copy into the final slot:

ts
function copyCandidateToFinal(slug: string, candidatePath: string) {
  const imagesRoot = ensureImagesDir()
  const finalPath = path.join(imagesRoot, `${slug}.jpg`)
  fs.copyFileSync(candidatePath, finalPath)
  return finalPath
}

Step 4: Building the Prompt from Frontmatter

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:

ts
function hashSlug(slug: string): number {
  let hash = 0
  for (const char of slug) {
    hash = (hash * 31 + char.charCodeAt(0)) >>> 0
  }
  return hash
}

function selectGradientBySlug(slug: string, pairs: BackgroundPair[]) {
  const hash = hashSlug(slug)
  const pairIndex = hash % pairs.length
  const scheme = (hash >>> 8) & 1 ? 'dark' : 'light'
  return { pair: pairs[pairIndex], scheme }
}

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:

  1. Confirm OPENAI_API_KEY is set.
  2. 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.
  3. Build the prompt, using --prompt verbatim if provided, otherwise running it through buildArticleImagePrompt.
  4. Ensure the candidate directory exists for that slug.
  5. 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.
  6. 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:

ts
import matter from 'gray-matter'

function updateFrontmatterImagePath(
  filePath: string,
  frontmatter: Record<string, any>,
  slug: string,
  prompt: string,
) {
  const articleImageRelative = path.posix.join('..', 'images', `${slug}.jpg`)
  const updated = {
    ...frontmatter,
    articleImage: articleImageRelative,
    imagePromptOverride: prompt,
  }
  const original = fs.readFileSync(filePath, 'utf8')
  const parsed = matter(original)
  const updatedDoc = matter.stringify(parsed.content.trimStart(), updated)
  fs.writeFileSync(filePath, updatedDoc, 'utf8')
}

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:

ts
const imagesDir = ensureImagesDir()
const absoluteImagePath = path.join(imagesDir, `${slug}.jpg`)

if (fs.existsSync(absoluteImagePath)) {
  // image already exists — optionally just fix the frontmatter path
  return
}

const { prompt } = buildArticleImagePrompt({
  title: frontmatter.title,
  subtitle: frontmatter.subtitle,
  slug,
  logos: frontmatter.tools,
})

const webpBuffer = await generateImageBuffer(openai, prompt)
const jpegBuffer = await sharp(webpBuffer)
  .resize({ width: 1536, height: 1024, fit: 'cover' })
  .jpeg({ quality: 82 })
  .toBuffer()

fs.writeFileSync(absoluteImagePath, jpegBuffer)
// then write articleImage into frontmatter

Testing the Full Pipeline

Create a test article at content/draft/test-hero.md:

markdown
---
title: Test Hero Image
subtitle: Minimal landscape concept for a developer blog
slug: test-hero-image
tools:
  - Next.js
---

Body text here.

Run it with auto-accept:

bash
pnpm content:image content/draft/test-hero.md --accept

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

ParamValueWhy it's set this way
modelgpt-image-2Current state-of-the-art image model as of July 2026
size1536x1024Matches the existing landscape hero-image contract
qualitymediumCost-to-quality tradeoff for images that get resized anyway
n1One candidate per call
output_formatwebpSharp converts this to JPEG for the site
backgroundopaquegpt-image-2 doesn't support transparent backgrounds
Response fieldb64_jsonBase64-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.

Thanks, Matija