DEV Community

Cover image for Your programmatic SEO pipeline needs a judge, not a template
Kiragu Maina
Kiragu Maina

Posted on

Your programmatic SEO pipeline needs a judge, not a template

Programmatic SEO has one failure mode that matters: two thousand pages with the same skeleton and a keyword swapped in. Google calls them doorway pages, and it is right to.

When I took over MyPhotoAI as its only engineer, the manifest had 2,158 keywords worth 198,460 searches a month. I built the loop that turns a keyword into a page. Then I built the thing that refuses pages, and that turned out to be most of the work. This post is about the refusing part: a two-part quality gate that runs before any money is spent, and the retry loop that feeds its verdict back into the next attempt.

Order matters more than the model

A page on MyPhotoAI carries a grid of generated photos. Each render costs real money. The copy costs a fraction of a cent. So the worker never renders an image for a page whose copy has not already passed:

async function processPage(entry: KeywordEntry, manifest: Manifest) {
  // 1. Copy, inside a validation loop (up to 3 attempts)
  const content = await generateHelpfulContent(entry);
  if (!content) {
    updateStatus(manifest, entry.slug, 'failed');
    return null;                       // no images were rendered for this page
  }

  // 2. Only now do we spend on images
  const images = await generateImages(entry);

  // 3. Assemble, validate, write
  const html = buildPage(entry, content, images);
  const validation = validatePage(html, entry.slug);
  if (!validation.valid) return null;
  return writePage(entry.slug, html);
}
Enter fullscreen mode Exit fullscreen mode

A page that fails three times costs three text generations and zero renders.

The spend gate

Part 1: heuristics, no API call

The first pass is deterministic and free. It reads every string in the generated content (hero, benefits, FAQs, CTA and the layout-specific sections) and deducts from 100:

  • Filler phrases. A list of 42 strings AI copy reaches for: "in today's digital age", "look no further", "a game changer", "unlock the potential", "elevate your". Each hit deducts.
  • Robotic starters. "This ensures", "Additionally,", "Furthermore,", "Ultimately,". Three or more on one page: minus 15.
  • Keyword density. Over 2.0 percent, or more than 8 occurrences, is stuffing.
  • Sentence length variety. Low standard deviation reads like a metronome: minus 10.
  • Benefit specificity. "Helps you get results" with no number, timeframe or outcome: minus 8 each.
  • FAQ quality. An answer that restates the question, or runs under 15 words: minus 5 each.
const score = Math.max(0, 100 - deductions);
if (score < 50) return { pass: false, score, issues };   // judge never called
Enter fullscreen mode Exit fullscreen mode

That short-circuit keeps the judge's cost proportional to pages that have a chance.

Part 2: an LLM as a Search Quality Rater

Survivors go to a second model with a rubric that mirrors Google's Helpful Content language. The prompt opens with:

You are a Google Search Quality Rater evaluating a landing page for the keyword "{keyword}". Be harsh; Google is actively demoting AI slop.

and scores five things: keyword naturalness (20), E-E-A-T signals (25), conversational tone (20), genuine helpfulness (20), originality (15). It returns JSON: a score, a verdict of pass, rewrite or fail, and lists of issues and suggestions.

Combining them

const combined = Math.round(heuristics.score * 0.4 + llmResult.score * 0.6);
const pass = combined >= 75 && llmResult.verdict === 'pass';
Enter fullscreen mode Exit fullscreen mode

Both have to agree. And if the judge's JSON does not parse, the result is rewrite, never pass. A parsing error must not let a page through.

Two judges, one verdict

The retry is not a retry

When the gate fails, the next attempt is not the same prompt again. The judge's issues and suggestions are appended to the brief:

entry.contentBrief += `
Previous attempt scored ${helpfulness.score}. Fix these:
${helpfulness.issues.map(i => `- ${i}`).join('\n')}
Suggestions:
${helpfulness.suggestions.map(s => `- ${s}`).join('\n')}`;
Enter fullscreen mode Exit fullscreen mode

Three attempts, then the manifest marks the keyword failed with the top issue recorded, and moves on. Some keywords never publish. That is the gate working.

What I would tell you to copy

  1. Put the gate before the expensive step, not after publishing.
  2. Make the cheap check free and deterministic, and let it short-circuit.
  3. Give the judge a rubric with points, not "is this good?".
  4. Require both to agree, and treat any parsing failure as a rejection.
  5. Feed the verdict back into the next attempt, and cap the attempts.

The rest of the engine (deterministic per-slug layout variation so no two pages share a skeleton, static HTML on Cloudflare Pages with a build guard for its SPA-mode trap, and an IndexNow queue that will not submit a URL until it has seen the page live) is in the full write-up: How I built a 1,898-page programmatic SEO engine with AI quality gates, static HTML and IndexNow.

I build programmatic SEO pipelines and audit existing ones. kiragu@alkenacode.dev or Contra. Case studies and more of this work at kiragu.alkenacode.dev.

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.