---
title: "Generate Hero Images with gpt-image-2 — Next.js Pipeline"
slug: "generate-hero-images-gpt-image-2-nextjs-pipeline"
published: "2026-07-11"
updated: "2026-07-21"
validated: "2026-07-21"
categories:
  - "Next.js"
tags:
  - "generate hero images"
  - "gpt-image-2"
  - "OpenAI Images API"
  - "Next.js content pipeline"
  - "Sharp webp to jpeg"
  - "gray-matter frontmatter"
  - "hero image automation"
  - "image prompt engineering"
  - "candidate image review"
  - "image generation CLI"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "openai@^6.46.0"
  - "sharp@^0.34.0"
  - "pnpm@>=8"
  - "tsx@latest"
  - "typescript@5.5.0"
  - "gray-matter@latest"
  - "next.js@15"
  - "node@20"
status: "stable"
llm-purpose: "Generate hero images with gpt-image-2 in a Next.js pipeline — automate prompts, convert WebP to JPEG with Sharp, and patch frontmatter. Follow the CLI…"
llm-prereqs:
  - "Access to OpenAI (gpt-image-2)"
  - "Access to Next.js"
  - "Access to Sharp"
  - "Access to Node.js"
  - "Access to gray-matter"
llm-outputs:
  - "Completed outcome: Generate hero images with gpt-image-2 in a Next.js pipeline — automate prompts, convert WebP to JPEG with Sharp, and patch frontmatter. Follow the CLI…"
---

**Summary Triples**
- (CLI script, reads, markdown frontmatter using gray-matter)
- (CLI script, constructs, an image prompt from article title and subtitle)
- (CLI script, calls, OpenAI gpt-image-2 via openai SDK (openai@^6.46.0) to generate WebP images)
- (Generated WebP, is converted to, JPEG using Sharp and saved as the final hero image)
- (Script, writes, candidate images into a per-article review folder before committing)
- (Review flow, requires, manual selection of a candidate image to patch the article frontmatter with the image path)
- (Execution, is intended to run, via pnpm content:image using tsx to execute the TypeScript CLI)
- (Environment, must provide, OPENAI_API_KEY environment variable)
- (Image files, are stored, in the repo's image folder structure and referenced by relative path in frontmatter)

### {GOAL}
Generate hero images with gpt-image-2 in a Next.js pipeline — automate prompts, convert WebP to JPEG with Sharp, and patch frontmatter. Follow the CLI…

### {PREREQS}
- Access to OpenAI (gpt-image-2)
- Access to Next.js
- Access to Sharp
- Access to Node.js
- Access to gray-matter

### {STEPS}
1. Install prerequisites and dependencies
2. Create an OpenAI image wrapper
3. Convert WebP output to JPEG with Sharp
4. Manage images and candidate folders
5. Build deterministic or random prompts
6. Run the interactive CLI for review
7. Use non-interactive fallback for automation

<!-- llm:goal="Generate hero images with gpt-image-2 in a Next.js pipeline — automate prompts, convert WebP to JPEG with Sharp, and patch frontmatter. Follow the CLI…" -->
<!-- llm:prereq="Access to OpenAI (gpt-image-2)" -->
<!-- llm:prereq="Access to Next.js" -->
<!-- llm:prereq="Access to Sharp" -->
<!-- llm:prereq="Access to Node.js" -->
<!-- llm:prereq="Access to gray-matter" -->
<!-- llm:output="Completed outcome: Generate hero images with gpt-image-2 in a Next.js pipeline — automate prompts, convert WebP to JPEG with Sharp, and patch frontmatter. Follow the CLI…" -->

# Generate Hero Images with gpt-image-2 — Next.js Pipeline
> Generate hero images with gpt-image-2 in a Next.js pipeline — automate prompts, convert WebP to JPEG with Sharp, and patch frontmatter. Follow the CLI…
Matija Žiberna · 2026-07-11

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:

```
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:

```
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

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

Thanks,
Matija

## LLM Response Snippet
```json
{
  "goal": "Generate hero images with gpt-image-2 in a Next.js pipeline — automate prompts, convert WebP to JPEG with Sharp, and patch frontmatter. Follow the CLI…",
  "responses": [
    {
      "question": "What does the article \"Generate Hero Images with gpt-image-2 — Next.js Pipeline\" cover?",
      "answer": "Generate hero images with gpt-image-2 in a Next.js pipeline — automate prompts, convert WebP to JPEG with Sharp, and patch frontmatter. Follow the CLI…"
    }
  ]
}
```