← LeaderboardThe Skill Leaderboard

seo-blog-images

Plan, generate, compress, verify, and wire high-CTR cover images and inline proof visuals into blog posts (MDX, Astro, Next.js, Hugo, HTML) with strict image SEO checks. Use when adding a blog cover or OG image, generating inline visuals without dark icon-box AI slop, capturing real product screenshots or data charts, or auditing image alt text, filenames, and Core Web Vitals attributes.

Published by Illyism ↗Illyism/seo-skills

Try your first task

A source-grounded starter task has not been verified for this skill yet.

SKILL.md

name: seo-blog-imagesdescription: Plan, generate, compress, verify, and wire high-CTR cover images and inline proof visuals into blog posts (MDX, Astro, Next.js, Hugo, HTML) with strict image SEO checks. Use when adding a blog cover or OG image, generating inline visuals without dark icon-box AI slop, capturing real product screenshots or data charts, or auditing image alt text, filenames, and Core Web Vitals attributes.metadata:   author: illyism  source: https://il.ly/skills/seo-blog-images  version: 1.0.0

Blog images and image SEO

The default AI blog visual hurts the post: a dark background with 5 glowing wireframe icons, garbled infographic labels, a 2.4 MB PNG named image-1.png, and no width/height, so the layout jumps on load. This workflow picks the right visual type per slot, produces it (real capture, deterministic chart, or a short-prompt generation), reads every letter back, and wires SEO-complete tags into the post.

Step 0: Find where images live

Which framework is the repo?
├── `next.config.*`, `astro.config.*`, `vite.config.*`, Remix -> files in `public/blog/<slug>/`, src `/blog/<slug>/<name>.webp`
├── Hugo (`hugo.toml` / `config.toml`) -> page bundle next to `index.md`, or `static/blog/<slug>/`
├── Jekyll (`_config.yml`) -> `assets/blog/<slug>/`
└── Other -> match the path pattern of an existing image in a published post

Never invent a new image directory when the repo already has one; mixed locations break the size audit and CDN caching rules.

Step 1: Pick the visual type per slot

Walk this for the cover and for each <h2> that needs a visual:

What is this slot for?
├── Software product review or UI tutorial (`### 1. ToolName`)?
│   └── Path B: real Playwright screenshot or short `<video>` loop.
│       Never ask an image model to draw a real product's UI; readers spot fake dashboards and it misrepresents the product.
├── Benchmark, dataset, or any chart with numbers?
│   └── Path C: deterministic SVG chart from the real numbers.
│       Never ask an image model for charts or tables; it invents bar heights and misspells values.
├── Cover / OpenGraph (`og:image`)?
│   └── Path A: 16:9, 12-22 word prompt, 1 focal scene + a 3-6 word headline badge.
└── Inline concept (workflow, pricing contrast, architecture)?
    ├── Does a 2-4 word contrast label make the point in half a second?
    │   └── Path A: topic-literal scene + 2-4 quoted labels (`"MANUAL CSV"` vs `"LIVE WEBHOOK"`). No invented numbers.
    └── Otherwise -> Path A: zero-text close-up, 12-20 words, tight framing.

Step 2: Prompt rules (Path A only)

  1. 12-22 words. Long prompts make image models crowd the frame with sub-panels and tiny unreadable text; short prompts produce one bold subject.
  2. No dark icon-box templates. Never prompt for "cards with glowing wireframe icons", "holographic brain", "dark monochrome desk with ice-blue highlights", or trophy metaphors. Describe tangible objects under bright, high-contrast light.
  3. Cover headline badge: 3-6 quoted words, using the target query plus a year or format badge, kept inside the center 80% of the frame so the 1.91:1 OG crop on X and LinkedIn does not cut it.
    • querybench.dev: "Crisp daylight 3D studio, cobalt Postgres server rack splitting into instant branches, bold headline 'POSTGRES CI BRANCHING', badge '2026 BENCHMARK'."
    • deployfast.dev: "High-contrast isometric shipping container launching from a GitHub terminal into orbit, bold text '11 BEST NEXT.JS HOSTS', badge '2026 TESTED'."
  4. Inline contrast labels: 2-4 quoted labels, only real numbers. Any figure in a label must exist in the post or seo/kb/.
    • invoicekit.io: "Split studio scene: tangled red paper receipts labeled '0.8% BILLING FEE' versus a clean cobalt API card labeled '$19 FLAT', crisp daylight."

Step 3: Produce the file

Scaffold the image script (once per repo)

If scripts/seo-image.mjs does not exist, create it. It handles both generation (Path A) and compression of any existing PNG (Paths B and C), so every image goes through the same size pipeline.

// scripts/seo-image.mjs
// Generate: node scripts/seo-image.mjs --prompt "<12-22 words>" --out public/blog/<slug>/<keyword-name>.webp
// Compress: node scripts/seo-image.mjs --input /tmp/raw.png --out public/blog/<slug>/<keyword-name>.webp
// Options:  --quality 82 (lower it if the file is over budget), --model <openrouter-image-model>
import fs from "node:fs/promises";
import path from "node:path";
import { parseArgs } from "node:util";
import sharp from "sharp";

const { values: a } = parseArgs({
  options: {
    prompt: { type: "string" },
    input: { type: "string" },
    out: { type: "string" },
    quality: { type: "string", default: "82" },
    model: { type: "string", default: "google/gemini-2.5-flash-image" },
  },
});
if (!a.out || (!a.prompt && !a.input)) {
  console.error("Usage: --prompt <text> | --input <file>, plus --out <path.webp|.jpg>");
  process.exit(1);
}

let raw;
if (a.input) {
  raw = await fs.readFile(a.input);
} else {
  if (!process.env.OPENROUTER_API_KEY) {
    console.error("OPENROUTER_API_KEY is not set");
    process.exit(1);
  }
  const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      model: a.model,
      messages: [{ role: "user", content: a.prompt }],
      modalities: ["image", "text"],
      image_config: { aspect_ratio: "16:9" },
    }),
  });
  const data = await res.json();
  const url = data?.choices?.[0]?.message?.images?.[0]?.image_url?.url;
  if (!res.ok || !url) {
    console.error(`Generation failed (${res.status}): ${JSON.stringify(data).slice(0, 300)}`);
    process.exit(1);
  }
  raw = Buffer.from(url.replace(/^data:image\/\w+;base64,/, ""), "base64");
}

await fs.mkdir(path.dirname(a.out), { recursive: true });
const q = Number(a.quality);
const img = sharp(raw).resize({ width: 1536, withoutEnlargement: true });
const buf = /\.jpe?g$/i.test(a.out)
  ? await img.jpeg({ quality: q, mozjpeg: true }).toBuffer()
  : await img.webp({ quality: q }).toBuffer();
await fs.writeFile(a.out, buf);
const { width, height } = await sharp(buf).metadata();
console.log(`${a.out} ${width}x${height} ${Math.round(buf.length / 1024)} KB`);

Install sharp with the repo's package manager: pnpm add -D sharp (pnpm-lock.yaml), bun add -d sharp (bun.lock), yarn add -D sharp (yarn.lock), else npm i -D sharp. If the repo has no package.json (Hugo, Jekyll), put the script in /tmp/seo-image/, run npm init -y && npm i sharp there, and pass absolute --out paths, so the repo gains no Node tooling it does not use.

If OPENROUTER_API_KEY is missing, generate with the agent's built-in image tool, save the PNG to /tmp/, and run the script in --input mode.

Path A: generate

node scripts/seo-image.mjs --prompt "<prompt from Step 2>" --out public/blog/<slug>/<keyword-name>.webp

Use .jpg for the cover when the site's OG pipeline rejects WebP; use .webp everywhere else.

Path B: real screenshot

npx playwright install chromium   # first run only
npx playwright screenshot --viewport-size "1440, 900" --wait-for-timeout 1500 \
  "https://querybench.dev" /tmp/raw-shot.png
node scripts/seo-image.mjs --input /tmp/raw-shot.png --out public/blog/postgres-tools/querybench-explain-analyze-ui.webp

Path C: deterministic chart

For numbers from the post or seo/kb/proof/benchmarks/*.md, write a self-contained 1536x864 SVG: bar lengths computed from the values (cap the longest horizontal bar at 760px so right-hand value labels never clip at the 1536px edge), every value printed as a label, font-family="system-ui, -apple-system, sans-serif", dark text on a light background. Save it as /tmp/<name>.svg, then convert with --input /tmp/<name>.svg. Every number on the chart then matches the source exactly.

Step 4: Read back every image

Open every generated file with the agent's file-viewing tool right after saving. Image models misspell ("BENCHMMARK"), duplicate letters, and clip text at the edge.

  • Check every quoted word letter by letter and confirm it sits fully inside the frame.
  • Garbled, blurry, duplicated, or clipped text, or a dark icon-box layout: delete the file and regenerate with a 12-16 word prompt. After 2 failed attempts, switch that slot to zero-text mode.

Step 5: Wire it in with full image SEO

  1. Keyword kebab-case filenames naming the subject (postgres-zfs-branch-spinup-benchmark.webp). Never image1.png, hero.webp, screenshot-2026.png, or UUIDs, because the filename is one of the few text signals image search reads.
  2. alt text of 80-160 characters stating what the image shows in the post's terms. Never start with "Image of", "Picture of", or "An infographic showing"; screen readers already announce the image.
    • Bad: "An infographic of database branching speed" (43 chars, generic).
    • Good: "Comparison of 50GB Postgres CI branch creation times showing ZFS copy-on-write cloning in 1.8 seconds versus 14 minutes for pg_restore" (134 chars).
  3. Explicit width and height equal to the intrinsic pixels the script printed, on every <img> and <Image>, so the browser reserves the space and the layout does not shift.
  4. Loading priority:
    • Cover / above-the-fold hero only: priority (Next.js) or fetchpriority="high" + loading="eager", because it is the LCP element.
    • Every image below the fold: loading="lazy" and decoding="async". Never priority on body images; it competes with the hero for bandwidth.
  5. Size budgets:
    • Cover: 1200px or wider, 350 KB or less. Also set it in frontmatter (image / cover / openGraph.images) so X, LinkedIn, Slack, and Google Discover render the large card.
    • Inline: 220 KB or less, .webp. Over budget: re-run with --quality 72, then --quality 62.
  6. Placement: put inline images after the first paragraph of their <h2> section, never between the heading and its first sentence. Featured snippet extraction and readers both expect the text answer directly under the heading.
## How much faster is ZFS copy-on-write branching than pg_restore in CI?

Cloning a 50.4 GB Postgres 16 database with `pg_restore -j 4` takes a median of 14 minutes and 22 seconds in GitHub Actions. A ZFS copy-on-write snapshot boots the same 180-million-row dataset in 1.8 seconds.

<Image
  src="/blog/postgres-ci-testing/postgres-zfs-branch-spinup-benchmark.webp"
  alt="Benchmark comparison showing QueryBench ZFS copy-on-write booting a 50GB Postgres branch in 1.8 seconds compared to 14 minutes for pg_restore"
  width={1536}
  height={864}
  loading="lazy"
  decoding="async"
/>

Step 6: Audit before finishing

dir=public/blog/<slug>; post=content/blog/<slug>.mdx
for f in "$dir"/*; do
  n=$(basename "$f"); kb=$(( $(wc -c < "$f") / 1024 ))
  echo "$kb KB  $n"
  [ "$kb" -gt 350 ] && echo "  FAIL: over 350 KB"
  [ "$kb" -gt 220 ] && echo "  CHECK: only the cover may exceed 220 KB"
  echo "$n" | grep -qiE '^(image|img|hero|screenshot|untitled)[-_.0-9]|^[0-9a-f]{8}-' && echo "  FAIL: generic filename"
done
grep -oE 'alt="[^"]*"' "$post" | awk '{ l = length($0) - 6; s = (l < 80 || l > 160) ? "FAIL" : "ok"; print s, l, $0 }'

Done means:

  • Image files exist in the framework's image directory and the post file references them.
  • Each slot used the right path: screenshot for products, SVG chart for numbers, short prompt for cover and concepts.
  • Every generated image was viewed with zero misspelled or clipped text.
  • The audit prints no FAIL, and the only file over 220 KB is the cover.
  • The cover is in frontmatter/OG metadata; inline images sit after the first paragraph of their <h2> with width, height, and loading="lazy".