AM
AgenticMedia
SponsoredSponsored Banner
Domain.com - Your Global Address Book. Domains & Sites Starts at $X.XX/domain
ai-marketing
•7 min read

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.

AT

AgenticMedia Team

Content Creator

Written in Markdown
Pipeline diagram showing seed data scraping, LLM enrichment, schema validation, and static page rendering

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]
  1. 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.
  2. 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.
  3. 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.
  4. 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.

SponsoredSponsored Feature
Domain.com - Your Global Address Book. Domains & Sites Starts at $X.XX/domain
#Programmatic SEO#Astro#Content Automation#LLM Pipelines#Schema Validation
AT

AgenticMedia Team

Content Creator • @agenticmedia

Writer and technology enthusiast sharing engineering playbooks and digital optimization guides.