Business Ops

Automating Programmatic SEO: 100 Pages/Day with OpenAI & WordPress (2026)

Automating Programmatic SEO: 100 Pages/Day with OpenAI & WordPress (2026)

The short answer: yes, you can automate the creation and publishing of hundreds of SEO pages per day using OpenAI and the WordPress REST API. The pipeline is about 150 lines of Python. The API costs are negligible. The part that will actually make or break the project has nothing to do with code — it’s the data layer you build before you write a single line.

I’ve run this setup twice now.

The first time I got a lot of pages indexed and then watched a batch of them quietly disappear six weeks later. The second time I understood what had gone wrong, fixed it, and got substantially better results. Both runs taught me things worth knowing before you start.


What Programmatic SEO Actually Is (And What It Isn’t)

Programmatic SEO means generating pages at scale from a data source, where each page targets a distinct keyword and contains information that’s genuinely different from every other page in the set. The classic examples: Zillow’s city-level real estate pages, Yelp’s “best [business type] in [city]” pages, NomadList’s city comparison pages.

The key word in that definition is genuinely. Pages that swap out one word in a template and call it unique aren’t programmatic SEO — they’re doorway pages, and Google has been fairly effective at deindexing them en masse since the September 2023 helpful content update.

What makes programmatic SEO work is that each page is driven by data that’s actually different: different statistics, different local specifics, different pricing ranges, different conditions. The template handles the structure; the data handles the differentiation.

If your dataset only varies by city name or product name and everything else is identical, you don’t have a programmatic SEO play — you have a thin content problem waiting to surface.


The Project: Service-Location Pages at Scale

The second run (the one that actually worked) was a service directory covering 14 service categories across 200 UK cities. That’s 2,800 potential pages in the matrix.

Each row in the source data had:

  • City name, county, region, population tier
  • Average service cost for that city (pulled from an existing pricing dataset)
  • Average number of providers listed in that city
  • Regulatory context specific to that region (some services have local licensing variations)
  • Nearest transport links and average travel time from the city center (for service providers who travel)

That last column is the kind of thing that separates a useful page from a templated one. A user in Cardiff searching for a specific service doesn’t need the same page as a user in Edinburgh — they need a page that reflects Cardiff’s pricing, available providers, and relevant local context. The data makes that possible.

If you don’t have data at that level of specificity, go build it before you build the pipeline. It’s the only part of this that can’t be automated.


The Prompt That Produces Indexable Content

Model choice matters here. GPT-4o-mini handles most of this well and the cost is almost embarrassingly low — the entire 2,800-page run cost $18.40 in OpenAI API fees, which works out to about $0.0066 per page. GPT-4o produces noticeably better prose but at roughly 15x the cost. I used GPT-4o-mini for everything, then manually rewrote about 40 of the highest-priority pages to lift them.

The prompt structure that worked:

python

def build_prompt(row: dict) -> str:
    return f"""
You are writing a service information page for a UK directory website.

Write a complete, factually grounded page about {row['service']} services in {row['city']}, {row['county']}.

You must use the following data points in the content — do not fabricate figures:
- Average local cost: {row['avg_cost_range']}
- Number of listed providers: {row['provider_count']}
- Population tier: {row['population_tier']}
- Regional licensing note: {row['licensing_note']}
- Travel context: {row['travel_context']}

Requirements:
- Minimum 650 words
- H2 headings only (no H3s)
- Include a 4-question FAQ at the end using the exact format: Q: [question] / A: [answer]
- Write for someone actively looking to hire a {row['service']} provider in {row['city']}
- Do not use the phrases "look no further", "in today's world", "it's worth noting", or "in conclusion"
- Do not pad the content — every sentence should contain information the reader needs

Return valid JSON with these keys:
{{
  "title": "string (include city name and service)",
  "meta_description": "string (under 155 characters)",
  "slug": "string (lowercase, hyphenated)",
  "content": "string (full HTML body content)",
  "faq": [{{"question": "string", "answer": "string"}}]
}}
"""

The negative instructions matter more than they look. Without them, GPT-4o-mini will default to transition phrases and filler sentences that read as obviously generated and add no value. Specifying the exact phrases to avoid forces the model to find different constructions, and the output is cleaner.

The JSON output format makes the WordPress publishing step trivial — no parsing ambiguity.


The Python Pipeline

python

import openai
import requests
import json
import csv
import time
from base64 import b64encode

# Config
OPENAI_API_KEY = "your-key"
WP_URL = "https://yoursite.com/wp-json/wp/v2"
WP_USER = "api-user"
WP_APP_PASSWORD = "xxxx xxxx xxxx xxxx xxxx xxxx"  # WP Application Password
CATEGORY_ID = 12  # pre-created in WordPress
PAGES_PER_MINUTE = 8  # stay well under WP REST rate limits

client = openai.OpenAI(api_key=OPENAI_API_KEY)

def generate_page(row: dict) -> dict:
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": build_prompt(row)}],
        response_format={"type": "json_object"},
        temperature=0.4  # lower = more consistent structure
    )
    return json.loads(response.choices[0].message.content)

def publish_to_wordpress(page_data: dict, row: dict) -> dict:
    auth = b64encode(f"{WP_USER}:{WP_APP_PASSWORD}".encode()).decode()
    headers = {
        "Authorization": f"Basic {auth}",
        "Content-Type": "application/json"
    }

    # Build FAQ schema block
    faq_schema = {
        "@context": "https://schema.org",
        "@type": "FAQPage",
        "mainEntity": [
            {
                "@type": "Question",
                "name": faq["question"],
                "acceptedAnswer": {
                    "@type": "Answer",
                    "text": faq["answer"]
                }
            }
            for faq in page_data["faq"]
        ]
    }

    body = {
        "title": page_data["title"],
        "slug": page_data["slug"],
        "content": page_data["content"],
        "status": "draft",  # publish manually after spot checks
        "categories": [CATEGORY_ID],
        "meta": {
            "_yoast_wpseo_metadesc": page_data["meta_description"],
            "schema_json": json.dumps(faq_schema)
        },
        "tags": [row["service_tag_id"], row["city_tag_id"]]
    }

    response = requests.post(
        f"{WP_URL}/posts",
        headers=headers,
        json=body
    )
    return response.json()

def run_pipeline(csv_path: str, limit: int = 100):
    with open(csv_path, newline="", encoding="utf-8") as f:
        rows = list(csv.DictReader(f))

    published = 0
    errors = []

    for i, row in enumerate(rows[:limit]):
        try:
            page_data = generate_page(row)

            # Quality gate: skip pages under 600 words
            word_count = len(page_data["content"].split())
            if word_count < 600:
                print(f"SKIP (short): {row['city']} / {row['service']} — {word_count} words")
                errors.append({"row": row, "reason": "below word threshold"})
                continue

            result = publish_to_wordpress(page_data, row)
            published += 1
            print(f"[{published}] Published: {page_data['title']} (ID: {result.get('id')})")

            # Rate limiting
            if published % PAGES_PER_MINUTE == 0:
                print("Rate limit pause...")
                time.sleep(60)
            else:
                time.sleep(60 / PAGES_PER_MINUTE)

        except Exception as e:
            print(f"ERROR on row {i}: {e}")
            errors.append({"row": row, "reason": str(e)})

    print(f"\nDone. Published: {published} | Errors: {len(errors)}")
    return errors

if __name__ == "__main__":
    errors = run_pipeline("services_data.csv", limit=100)

A few decisions worth explaining:

status: "draft" not "publish" — the pipeline creates drafts. I ran a spot check on 20 pages (roughly 20% of each daily batch) before bulk-publishing them via a second script. The spot check takes 15–20 minutes and catches the occasional weird output before it goes live.

Temperature 0.4 — lower temperature produces more consistent structure but less variation in prose. At 0.7+, the tone shifts between pages in ways that look odd when you read them back-to-back. At 0.2, everything starts sounding the same. 0.4 is the range I’ve settled on for this type of content.

Rate limiting at 8 pages/minute — WordPress’s REST API doesn’t publish a hard rate limit, but hosting environments will start returning 429s if you hammer it. Eight pages per minute means 100 pages in about 13 minutes, which is gentle enough that I’ve never hit a limit.


The Quality Controls That Prevent Mass Deindexing

This is where the first run failed. I published 2,800 pages without quality gates, and 8 weeks later Google had deindexed 680 of them — almost all of them were pages where the data for that city was sparse, so the content came in under 450 words and offered little beyond what the template provided.

The quality controls I run now:

Word count floor at 600 words. Anything below that gets flagged and skipped. GPT-4o-mini rarely hits this threshold if the prompt is written correctly and the data row is populated, but sparse rows (missing the travel context column, for example) will produce short output.

Mandatory data field check before generation. If any of the key data fields are empty, the row gets skipped entirely. It’s better to have 2,100 complete pages than 2,800 where 700 are padded:

python

required_fields = ["avg_cost_range", "provider_count", "licensing_note", "travel_context"]
if any(not row.get(field) for field in required_fields):
    print(f"SKIP (missing data): {row['city']} / {row['service']}")
    continue

FAQ presence check. If the JSON comes back without four FAQ items, the page doesn’t publish. The FAQ is what I’m counting on for featured snippet and People Also Ask targeting, and a page without it is worth less.

Monthly GSC review. I pull all URLs from the page set into Search Console’s URL inspection in batches and look for pages with zero impressions after 60 days. Those pages get reviewed manually — either enriched with more data and republished, or set to noindex to protect crawl budget.


Sitemap and Internal Linking

Two things Google needs to index pages at scale: a sitemap that tells it they exist, and internal links that tell it they matter.

For the sitemap, I generate a custom XML file programmatically after each batch:

python

def generate_sitemap(page_urls: list, output_path: str):
    entries = "\n".join([
        f"""  <url>
    <loc>{url}</loc>
    <changefreq>monthly</changefreq>
    <priority>0.6</priority>
  </url>"""
        for url in page_urls
    ])
    sitemap = f"""<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
{entries}
</urlset>"""
    with open(output_path, "w") as f:
        f.write(sitemap)

For internal links, I have a set of hub pages — one per service category — that link out to every city page in that category. Those hub pages are manually written, longer, and more authoritative. They exist primarily to pass link equity down to the programmatic pages and to give Googlebot a crawl path into the set.

Without those hub pages, the programmatic pages are orphaned. Google will still find them via sitemap, but pages with zero internal links tend to rank worse and get crawled less frequently.


What the Results Looked Like

After 90 days running this setup on the second project:

Pages published: 2,614 (186 skipped due to quality gates)
Pages indexed per GSC: 1,980 (75.7% index rate — good for a new site in this niche)
Pages with at least one impression: 1,340
Pages ranking in positions 1–20: 294
Monthly organic sessions from this page set: 5,900
OpenAI API spend for all 2,614 pages: $17.24

Automating Programmatic SEO: 100 Pages/Day with OpenAI & WordPress (2026)

The 294 pages ranking in the top 20 are pulling disproportionate traffic — the top 50 of them account for about 3,800 of the 5,900 monthly sessions. The long tail is doing what it’s supposed to do: picking up low-volume searches that add up collectively.

Month 4 data (preliminary) is trending toward 8,000–9,000 sessions as more pages work their way up from positions 15–20 into the 5–12 range. That trajectory is normal for programmatic pages — they tend to index quickly but rank slowly as Google accumulates click data.

One thing I’d flag honestly: 1–3% of the published pages produced content that, reading it back, I wouldn’t be proud to show a user. Not factually wrong, but thin in a way the word count check didn’t catch — lots of sentences that technically said something but didn’t add much. Catching those requires human review, not just automated checks. I now keep a manual review target of 50 randomly sampled pages per 1,000 published.


When NOT to Do This?

Programmatic SEO at this scale only makes sense in specific situations.

You need a real data layer. If your differentiating data is just the keyword variable itself — city name, product name — don’t do this. You’ll build something that works briefly and then gets wiped in the next quality update.

You need a site with at least some authority. Entirely new domains publishing 2,000+ pages immediately look unnatural. I’d recommend being on a domain with at least some age and some manually-written content before going near this scale. The second project I’ve described here was run on a domain that had been live for 14 months with about 60 handwritten pages already indexed.

You need to be able to maintain it. Programmatic pages go stale. Pricing data changes. Providers come and go. If you’re not willing to build a refresh pipeline alongside the creation pipeline, the pages will drift from accurate to inaccurate over 12–18 months, which eventually hurts rather than helps.

And if your niche is YMYL — health, finance, legal — I wouldn’t touch this at all. The E-E-A-T bar for those topics requires genuine expertise on each page, and no amount of prompt engineering substitutes for that.


FAQ

Does Google penalize programmatic SEO? Not for programmatic SEO itself. What Google penalizes is thin content and doorway pages, which poorly executed programmatic SEO produces. Pages that are genuinely differentiated by real data, meet a minimum quality bar, and actually serve the user’s search intent get indexed and rank normally. The distinction Google cares about is whether the pages are useful — not how they were created.

How do I build the data layer if I don’t have proprietary data? A few options that work: scraping public data sources (average costs from quote aggregators, population data from official statistics, business count data from Companies House or local directories), licensing a data provider, or running a survey. The cheapest option is usually to combine public data sources. Whatever you do, the data needs to vary meaningfully across pages — not just the name.

Can I use n8n instead of Python for this pipeline? Yes. If you’re already running n8n (I’ve written about n8n workflow automation separately), this pipeline maps reasonably well onto an n8n workflow: HTTP Request node to call OpenAI, a Code node to validate the output, and another HTTP Request node to POST to the WordPress REST API. The Python approach gives you more control over error handling and rate limiting, but for teams already invested in n8n, it’s a viable alternative.

What WordPress setup do I need? Enable Application Passwords under Users → Profile. Install Yoast or Rank Math for meta field support via REST API. Make sure the WP REST API is accessible (it’s on by default). If you’re on a shared host, watch for PHP memory limits on bulk-creation runs — I’d recommend running this from a VPS or your local machine rather than from within WordPress itself.

How long before the pages start ranking? In my experience: initial indexing within 2–6 weeks (faster if you submit via Search Console and have hub pages linking in), first rankings appearing around weeks 4–8, meaningful traffic by month 3. The pages that rank fastest are the ones in less competitive city/service combinations. The high-competition city + high-competition service combinations take longer and often need supplementary link building to break into the top 20.

What happens if Google deindexes a batch of pages? It has happened to me once. The fix is to audit the deindexed pages, identify the common quality pattern (usually: too short, missing data, repetitive structure), pull those pages out of the index deliberately with noindex before Google does it en masse, fix the underlying data or template issue, and republish improved versions. Don’t just re-publish the same pages — they’ll get pulled again for the same reason.

How do I handle duplicate content across pages? Two things help: genuine data variation (covered above) and unique introductory paragraphs. The first 150 words of each page should contain city-specific and data-specific content, not boilerplate. Google’s duplicate content detection weighs the beginning of a page heavily. If your pages open with the same two sentences and then diverge, that’s a yellow flag even if the overall content is different.


The pipeline itself takes an afternoon to build. The data layer takes longer. Everything that went wrong in my first attempt came down to underinvesting in the data and shipping pages I wouldn’t have published manually. The automation doesn’t change that calculus — if anything, it amplifies it, because you can publish a bad decision 2,800 times instead of once.

Elizabeth Sramek
Written by

Elizabeth Sramek is an independent advisor on search visibility and demand architecture for B2B companies operating in high-competition markets. Based in Prague and working globally, she specializes in designing search presence for AI-mediated discovery and building category visibility that survives algorithmic shifts.