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);
}
A page that fails three times costs three text generations and zero renders.
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
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';
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.
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')}`;
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
- Put the gate before the expensive step, not after publishing.
- Make the cheap check free and deterministic, and let it short-circuit.
- Give the judge a rubric with points, not "is this good?".
- Require both to agree, and treat any parsing failure as a rejection.
- 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.