
Building an AI-Powered Image Pipeline for My Blog with Claude Code
How I used Claude Code on the web to design and implement automatic AI-generated featured images for my Nuxt blog, complete with Cloudflare Pages integration.
How This Blog Generates Its Own Images
Every post on this blog has a hero image. None of them were made by hand. A GitHub Action generates them automatically whenever a new post is pushed, using AI for both the prompt and the image itself. Here is how it works.
The Problem
A blog without images looks flat. But manually creating featured images for every post is tedious, and stock photos feel generic. I wanted something that actually reflects the content of each post, without any manual effort.
Two Layers
The system has two layers, each serving a different purpose.
Layer 1: Programmatic OG Images. Using nuxt-og-image with Satori, every post gets a text-based social card automatically. These are the fallback. If anything goes wrong with the AI images, social sharing still looks good.
Layer 2: AI-Generated Hero Images. A TypeScript script generates a unique hero image for each post through two steps: first a text model writes an image prompt based on the post content, then an image model turns that prompt into an actual image.
Both steps run through OpenRouter, which provides access to many models through a single API. The current setup uses Gemini 2.5 Flash for prompt generation and Gemini 3.1 Flash Image for the actual image. Cost is roughly $0.07 per image.
How It Works
The Content Schema
The blog uses Nuxt Content with three image-related fields in the frontmatter:
schema: z.object({
published: z.boolean(),
created_at: z.date(),
tags: z.array(z.string()).optional(),
image: z.string().optional(),
imageAlt: z.string().optional(),
imagePrompt: z.string().optional(),
})
The imagePrompt field stores the prompt that was used to generate the image. This makes regeneration straightforward and keeps things transparent.
Prompt Generation
A system prompt defines the visual style for the blog: abstract, modern, deep blues and purples, no literal depictions of laptops or code screens. The text model receives the blog post content along with this system prompt and returns a short image prompt (under 200 characters).
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
Authorization: `Bearer ${OPENROUTER_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'google/gemini-2.5-flash',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
max_tokens: 200,
}),
})
If a post already has an imagePrompt in its frontmatter, the script skips this step and reuses the existing prompt.
Image Generation
The image model receives the prompt and returns a base64-encoded image, which gets saved to public/images/blog/. The frontmatter is then updated with the image path, alt text, and the prompt that was used.
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
Authorization: `Bearer ${OPENROUTER_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'google/gemini-3.1-flash-image-preview',
modalities: ['image', 'text'],
messages: [{
role: 'user',
content: [{ type: 'text', text: `Generate a 1200x630 blog hero image: ${prompt}` }],
}],
}),
})
The Style Guide
The quality of the images depends on the system prompt. Here is the gist of what works well:
- Abstract geometric patterns, flowing light trails, data streams
- Deep blues, purples, and teals with accent colors
- Enough negative space for text overlay
- No literal depictions, no stock photo cliches, no faces
Each image ends up feeling like it belongs to the same blog while still being distinct.
The Pipeline
Everything runs in a GitHub Action. No local tooling required.
name: Generate Blog Images
on:
push:
branches: [main]
paths:
- 'content/blog/**'
workflow_dispatch: {}
jobs:
generate-images:
runs-on: ubuntu-latest
if: github.actor != 'github-actions[bot]'
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
- run: pnpm install --frozen-lockfile
- name: Generate missing blog images
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
run: npx tsx scripts/generate-blog-images.ts
- name: Commit generated images
run: |
git add public/images/blog/ content/blog/
git diff --cached --quiet || git commit -m "Generate blog hero images" && git push
The flow:
- Push a new blog post to
main - The action detects the change, generates the prompt and image
- It commits the image and updated frontmatter back to the repo
- Cloudflare Pages picks up the new commit and deploys
The github.actor check prevents the action from running on its own commits, avoiding an infinite loop. Posts that already have an image field are skipped, so each image is only generated once.
The Result
| Scenario | What Happens |
|---|---|
New post, no imagePrompt | Text model generates a prompt, image model generates the image |
New post, has imagePrompt | Skips prompt generation, generates the image from the existing prompt |
Existing post with image | Skipped entirely |
| Need to regenerate | Remove the image field from frontmatter, push |
The whole pipeline runs in about 30 seconds per image. For a blog that publishes occasionally, the cost is negligible.
Takeaways
Store your prompts. Keeping the prompt in frontmatter means you can always see what generated an image, tweak it, and regenerate. It also means the image model step can run independently from the prompt generation step.
Separate prompt generation from image generation. Text models are good at understanding content and writing prompts. Image models are good at generating visuals. Letting each do what it is best at produces better results than trying to do everything in one step.
Always have a fallback. The site works fine without hero images. The programmatic OG images ensure social sharing always looks professional, even if the AI pipeline has a bad day.
Keep it boring in CI. The script checks for an existing image field before doing anything. No unnecessary API calls, no surprise costs. Write a post, push, and forget about it.
