Programmatic SEO with AI Agents: Generate 1,000 Verified Landing Pages
An architectural guide to AI-agent-driven programmatic SEO: seed data scraping, LLM content enrichment, schema validation, and rendering thousands of pages with Astro content collections.
AgenticMedia Team
Content Creator

TL;DR / Key Takeaways
Programmatic SEO (pSEO) done well is a data pipeline with an LLM enrichment step in the middle — not “ask an AI to write 1,000 blog posts.” The architecture that actually holds up to Google’s helpful-content standards and doesn’t collapse into duplicate-content penalties:
- Seed data quality determines everything downstream. A pSEO system generates variation around real, differentiated data points (city-specific pricing, product-specific specs, category-specific comparisons) — if your seed data doesn’t vary meaningfully, no amount of LLM rewriting will make 1,000 pages genuinely distinct.
- Schema validation is not optional; it’s the gate that prevents a bad LLM output from becoming 1,000 broken pages. Validate every generated record against a strict schema before it ever reaches the render step.
- Astro content collections with Zod schemas give you type-safe, build-time-validated content — a malformed record fails the build instead of shipping a broken page to production.
- Deduplication and thin-content detection must run before publish, not after — check generated content for excessive similarity across pages using embedding-based similarity scoring, and hold back or regenerate anything too close to an existing page.
- This is a batch pipeline, not a real-time agent loop. Running each page through an LLM one at a time in production is slow and expensive — generate offline in batches, validate, then commit to your content collection.
Core Architecture: The Four-Stage Pipeline
[Seed Data Source] → [LLM Enrichment] → [Schema Validation] → [Static Page Render]
- Seed data scraping/sourcing — structured base data (e.g., a list of cities, product categories, or comparison pairs) pulled from a public API, a scraped source, or an internal database.
- Dynamic content enrichment — an LLM call per seed record, generating the unique prose, FAQ content, and metadata that differentiates each page beyond the templated structure.
- Schema validation — every enriched record is validated against a strict schema before it’s allowed into the content collection; malformed or incomplete records are rejected and flagged for regeneration.
- Static rendering — Astro (or Next.js) content collections consume the validated dataset at build time, generating one static page per record.
Practical Tutorial: Building the Pipeline
Step 1 — Seed Data Structure
Define your seed schema first — this is the skeleton every generated page will vary around. Example: a “best [tool category] for [city]” pSEO pattern.
// seed-schema.ts
interface SeedRecord {
city: string;
state: string;
population: number;
category: string; // e.g., "coworking spaces"
localDataPoints: {
avgPrice: number;
providerCount: number;
topProviders: string[]; // real, sourced names — not invented
};
}
The localDataPoints object is what makes each page genuinely different rather than a templated shell with a city name swapped in — this is the difference between programmatic SEO that ranks and a pattern Google’s spam systems are specifically tuned to catch.
Step 2 — LLM Enrichment Node
import anthropic
import json
client = anthropic.Anthropic()
ENRICHMENT_SYSTEM_PROMPT = """You are an SEO content writer generating a landing
page section. You will be given structured local data. Write content that uses
the SPECIFIC data provided — never invent statistics, names, or facts not present
in the input. Respond with valid JSON only, matching this schema:
{
"meta_title": string (50-60 chars),
"meta_description": string (150-160 chars),
"intro_paragraph": string (80-120 words, must reference at least 2 specific
data points from the input),
"faq_items": [{"question": string, "answer": string}] (exactly 4 items)
}
"""
def enrich_seed_record(seed: dict) -> dict:
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1500,
system=ENRICHMENT_SYSTEM_PROMPT,
messages=[{
"role": "user",
"content": f"Generate content for this record:\n{json.dumps(seed, indent=2)}"
}]
)
return json.loads(response.content[0].text)
The explicit instruction to never invent statistics not present in the input is the single most important line in this prompt — the leading cause of pSEO systems producing embarrassing, credibility-damaging errors is an LLM confidently fabricating a local statistic that wasn’t in the seed data.
Step 3 — Schema Validation Gate
from pydantic import BaseModel, field_validator
from typing import List
class FAQItem(BaseModel):
question: str
answer: str
class EnrichedContent(BaseModel):
meta_title: str
meta_description: str
intro_paragraph: str
faq_items: List[FAQItem]
@field_validator("meta_title")
@classmethod
def title_length(cls, v):
if not (40 <= len(v) <= 65):
raise ValueError(f"meta_title length {len(v)} outside acceptable range")
return v
@field_validator("meta_description")
@classmethod
def description_length(cls, v):
if not (140 <= len(v) <= 165):
raise ValueError(f"meta_description length {len(v)} outside acceptable range")
return v
@field_validator("faq_items")
@classmethod
def faq_count(cls, v):
if len(v) != 4:
raise ValueError(f"Expected exactly 4 FAQ items, got {len(v)}")
return v
def validate_or_flag(seed: dict, raw_output: dict) -> tuple[bool, EnrichedContent | None, str]:
try:
validated = EnrichedContent(**raw_output)
return True, validated, ""
except Exception as e:
return False, None, str(e)
Records that fail validation are logged to a regeneration queue, not silently dropped or force-published with malformed metadata — a build pipeline that fails loudly on bad data is far cheaper to fix than 1,000 live pages with broken meta descriptions discovered after a Search Console crawl report.
Step 4 — Deduplication / Similarity Check
Before committing validated records to the content collection, run a similarity check to catch content that’s too structurally similar despite passing schema validation:
from sentence_transformers import SentenceTransformer
from sklearn.metrics.pairwise import cosine_similarity
import numpy as np
model = SentenceTransformer('all-MiniLM-L6-v2')
def flag_similar_content(intro_paragraphs: list[str], threshold: float = 0.92) -> list[tuple[int, int]]:
embeddings = model.encode(intro_paragraphs)
similarity_matrix = cosine_similarity(embeddings)
flagged_pairs = []
for i in range(len(intro_paragraphs)):
for j in range(i + 1, len(intro_paragraphs)):
if similarity_matrix[i][j] > threshold:
flagged_pairs.append((i, j))
return flagged_pairs
Pages flagged above the similarity threshold get sent back through enrichment with a stronger differentiation instruction, referencing what made them too similar — this closes the loop before publish rather than after a manual content audit.
Step 5 — Astro Content Collection Schema
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
const landingPages = defineCollection({
type: 'data',
schema: z.object({
city: z.string(),
state: z.string(),
category: z.string(),
metaTitle: z.string().min(40).max(65),
metaDescription: z.string().min(140).max(165),
introParagraph: z.string(),
faqItems: z.array(z.object({
question: z.string(),
answer: z.string(),
})).length(4),
localDataPoints: z.object({
avgPrice: z.number(),
providerCount: z.number(),
topProviders: z.array(z.string()),
}),
}),
});
export const collections = { landingPages };
Astro validates every record against this Zod schema at build time — a malformed record fails the build with a clear error rather than silently shipping a broken page, which is the safety net your Python-side Pydantic validation should have already caught, applied a second time at the framework layer.
Step 6 — Dynamic Page Rendering
---
// src/pages/[category]/[city].astro
import { getCollection } from 'astro:content';
export async function getStaticPaths() {
const pages = await getCollection('landingPages');
return pages.map(page => ({
params: { category: page.data.category, city: page.data.city },
props: { page },
}));
}
const { page } = Astro.props;
const { metaTitle, metaDescription, introParagraph, faqItems, localDataPoints } = page.data;
---
<html>
<head>
<title>{metaTitle}</title>
<meta name="description" content={metaDescription} />
</head>
<body>
<h1>Best {page.data.category} in {page.data.city}, {page.data.state}</h1>
<p>{introParagraph}</p>
<section>
<h2>Local Data</h2>
<p>Average price: ${localDataPoints.avgPrice} | {localDataPoints.providerCount} providers</p>
</section>
<section>
<h2>Frequently Asked Questions</h2>
{faqItems.map(item => (
<div>
<h3>{item.question}</h3>
<p>{item.answer}</p>
</div>
))}
</section>
</body>
</html>
getStaticPaths() generates one static HTML page per validated record at build time — for 1,000 records, this is 1,000 pre-rendered, fast-loading pages with no runtime server cost per request.
Tool / Solution Comparison Table
| Stage | Recommended Tool | Alternative | Why |
|---|---|---|---|
| Seed data sourcing | Public APIs, licensed datasets | Web scraping (Playwright/Scrapy) | Prefer structured, licensed sources over scraping where available — cleaner data and lower legal risk |
| LLM enrichment | Claude API (batch mode) | GPT-4o, Gemini | Strong instruction-following for “don’t fabricate data” constraints, critical for this use case |
| Schema validation | Pydantic (Python) + Zod (Astro) | JSON Schema + Ajv | Dual validation at generation-time and build-time catches different failure classes |
| Similarity/dedup check | Sentence-transformers + cosine similarity | Embedding API + vector DB | Local sentence-transformers avoids per-check API cost at 1,000+ record scale |
| Static rendering | Astro content collections | Next.js with generateStaticParams |
Astro’s built-in Zod schema validation at build time is a strong fit for this pipeline specifically |
Actionable Checklist / Next Steps
- Source or build seed data with genuine per-record variation — verify your data actually differs meaningfully before writing a single enrichment prompt.
- Instruct the LLM explicitly never to fabricate statistics or facts absent from the seed input.
- Validate every enriched record against a strict schema (Pydantic or equivalent) before it enters your content collection.
- Run a similarity/deduplication check across generated intro content before publish, not after.
- Mirror validation at the framework layer (Astro Zod schemas) so a bad record fails the build loudly rather than shipping silently.
- Treat this as an offline batch pipeline — generate, validate, and commit in batches; don’t generate content live at request time.
- Monitor Search Console post-launch for thin-content or duplicate-content flags on a sample of pages before scaling beyond the initial batch.
Next in this series: Cloudflare Workers vs. Vercel for Hosting Micro-SaaS Applications — Day 10 covers where to deploy the static output this pipeline generates.
AgenticMedia Team
Content Creator • @agenticmedia
Writer and technology enthusiast sharing engineering playbooks and digital optimization guides.
