Programmatic SEO is generating landing pages at scale from structured data rather than writing each one by hand. A directory with a page per city, a tool aggregator with a page per integration, or an ecommerce site with filter combination pages are all programmatic content.
The pattern has been abused enough that Google's March 2026 spam update explicitly expanded enforcement against scaled content abuse. Which means the quality threshold is no longer publish something unique per page. It is publish something genuinely useful per page, and the distinction matters because most programmatic approaches clear the first bar and fail the second.
When Programmatic Works and When It Produces Spam
The useful test is whether each generated page answers a question someone would actually ask, with information specific enough to be worth a page.
A page for best restaurants in [city] where the restaurant data is real, curated, and meaningfully different per city works because the answer genuinely changes by location.
A page for [keyword] + [city] where the content is a template with the city name swapped and no actual local information is the thin content version. It looks programmatic to Google because it is, and the March 2026 enforcement targets exactly this pattern.
The question to ask before building: if I removed the variable and read only the template, would anything remain that is useful. If the template is hollow without its variables, the pages will be hollow with them.
Data Quality Determines Everything
A programmatic page is only as good as the data feeding it. This sounds obvious and it is where most implementations fail, because the data sourcing is treated as a prerequisite rather than the core of the project.
Good data sources: your own operational data that nobody else has, public datasets you have cleaned and enriched meaningfully, and verified third party data you have a right to use.
Bad data sources: scraped content from competitors, thinly reformatted public datasets, and AI generated descriptions adding no information to the underlying data.
# Example: a data quality gate before page generation
import pandas as pd
def validate_page_data(df: pd.DataFrame) -> pd.DataFrame:
"""Filter dataset to rows that produce genuine pages."""
# Minimum data completeness: require 5+ populated fields
min_fields = 5
df['field_count'] = df.notna().sum(axis=1)
# Unique content check: description must differ from template default
template_default = "Information about this location coming soon."
df['has_real_content'] = df['description'] != template_default
# Demand check: require evidence of search demand
df['has_demand'] = df['monthly_searches'] > 10
# Apply all gates
qualified = df[
(df['field_count'] >= min_fields) &
(df['has_real_content']) &
(df['has_demand'])
]
rejected = len(df) - len(qualified)
print(f"Qualified: {len(qualified)} | Rejected: {rejected}")
return qualified
That script rejects pages before they exist, which is the critical step most implementations skip. Publishing everything and pruning later costs crawl budget, risks a spam assessment, and is substantially harder to reverse than not publishing in the first place.
Template Design Is Content Design
The template is the content. Every element in it should exist because it serves the user, not because it fills space.
Elements worth including: the specific answer the page title promises, structured data unique to this instance, contextual internal links to related pages, and any editorial commentary that differs per page.
Elements that produce thin pages: a map embed with no surrounding context, a data table with no interpretation, a paragraph of generic text identical across every instance, and a FAQ section populated by rephrasing the title.
// A programmatic template with genuine per-page variation
// Each section adds value specific to this data point
function LocationPage({ location }) {
return (
<article>
<h1>{location.service} in {location.city}</h1>
{/* Unique per location: specific stats */}
<section>
<h2>{location.city} by the numbers</h2>
<table>
<tr><th>Population</th><td>{location.population}</td></tr>
<tr><th>Avg cost</th><td>{location.avgCost}</td></tr>
<tr><th>Providers</th><td>{location.providerCount}</td></tr>
</table>
</section>
{/* Unique: editorial content written or curated per location */}
{location.editorial && (
<section>
<h2>What to know about {location.service} here</h2>
<div dangerouslySetInnerHTML={{ __html: location.editorial }} />
</section>
)}
{/* Contextual links: related locations, not a global footer */}
<nav>
<h2>Nearby</h2>
<ul>
{location.nearby.map(loc => (
<li key={loc.slug}><a href={'/'+loc.slug}>{loc.city}</a></li>
))}
</ul>
</nav>
</article>
)
}
Internal Linking at Scale Is the Architecture Problem
Ten thousand programmatic pages with no links between them are ten thousand orphan pages, which is the discovery problem at scale covered in the architecture guide.
The linking strategy has to be built into the template rather than applied afterward, because nobody is going to manually link ten thousand pages.
Three patterns that work: related pages links based on the data's own relationships, which is the nearby section in the example above. Hub pages that aggregate subsets, functioning as category pages. And contextual links from editorial content on the main site into the programmatic set, which is the bridge between hand written authority and generated coverage.
All three should be generated from the data, tested to confirm the links are crawlable anchor tags rather than JavaScript navigation, and verified to not produce circular linking patterns that waste crawl without adding depth.
Canonical and Index Control at Scale
Not every generated page should be indexed. A set of fifty thousand pages where forty thousand are thin dilutes the domain, per the pruning guide.
The quality gate script above handles the before publishing decision. For pages already live, apply noindex dynamically based on content completeness:
# Nginx: noindex pages below quality threshold
# Assumes your app sets an X-Page-Quality header
map $upstream_http_x_page_quality $robots_tag {
"low" "noindex, follow";
default "index, follow";
}
server {
location / {
add_header X-Robots-Tag $robots_tag;
}
}
Self referencing canonicals on every generated page, using absolute URLs built from the same slug logic that generated the URL in the first place. Canonical mismatches at scale produce thousands of conflicting signals, per the canonicalization guide.
Schema Generation From the Same Data
If your data is structured enough to generate pages, it is structured enough to generate schema. The mapping should be built into the template rather than handled separately:
function generateSchema(location) {
return {
"@context": "https://schema.org",
"@type": "LocalBusiness",
"name": location.businessName,
"address": {
"@type": "PostalAddress",
"addressLocality": location.city,
"addressRegion": location.state
},
"aggregateRating": location.reviewCount > 0 ? {
"@type": "AggregateRating",
"ratingValue": location.avgRating,
"reviewCount": location.reviewCount
} : undefined
}
}
The conditional on review count matters. Schema asserting an aggregate rating for a page with no reviews is the mismatch problem from the schema errors guide at scale, and scale makes every error worse.
Performance at Scale
Fifty thousand pages generating on every request will overload most servers and push response times past Core Web Vitals thresholds, per the Core Web Vitals guide.
Static generation at build time is the cleanest solution where the data does not change frequently. For dynamic data, ISR (Incremental Static Regeneration) or stale while revalidate caching keeps response times fast while allowing updates.
Server response time under 200 milliseconds is the target. Anything slower reduces how many pages Googlebot can crawl in a session, which directly constrains coverage on large sets.
Monitoring a Programmatic Set
Search Console's index coverage report is your primary instrument. Watch the indexed count against your generated count, and watch Crawled currently not indexed specifically, since that is where quality based exclusions appear.
A rising not indexed count after a spam update is the signal that matters most, and it requires action within days rather than months.
The NotioncCue AI Crawler Audit checks what specific crawlers receive from generated URLs, which catches rendering failures on templates where the content is supposed to be server rendered and is not.
Start your free NotioncCue trial and test a sample of generated pages rather than just the template. Data driven variability means some pages render correctly and others do not, depending on what the data contains.
Before generating anything: count the data fields that genuinely change per page. If fewer than three fields vary, the pages are near duplicates regardless of how different the variable values are, and the set will be assessed as such.
Common Questions
How many pages is too many?
There is no threshold on count. There is a threshold on quality. Ten thousand genuinely useful pages work. Ten thousand thin ones with the same template and swapped variables get caught.
Can AI generated descriptions save a thin template?
Usually not, because the output is generic rather than specific. AI can enhance a template that already has unique data per page. It cannot create genuine uniqueness from a variable and a prompt.
Should programmatic pages be in the sitemap?
Only the ones meeting your quality gate. A sitemap containing fifty thousand URLs where forty thousand are noindexed sends conflicting signals and wastes crawl budget, per the sitemap guide.