Programmatic SEO done right means generating thousands of landing pages from structured data, not templated garbage with swapped city names. I've watched teams build entire page hierarchies that rank because every page solved a real user problem—with real information backing it. In this post, I'll show you the exact approach I use to make programmatic SEO actually work.
Real data per page — the foundation that matters
The biggest mistake I see is teams generating pages where the only difference is the city name. A ride-hailing company might have 500 area pages, but if each one says "Get a ride in [City]" with identical copy, Google notices. Users notice. Rankings suffer.
The fix is brutal honesty: only generate a page if you have real data to populate it. For a travel or service business, that means:
- Actual pricing for that location (not a generic range)
- Real estimated durations or delivery times
- Genuine FAQs from customer support tickets in that region
- Localized content—landmarks, local regulations, competitor analysis
- Social proof tied to that area (reviews, case studies, local testimonials)
Let's say I'm building a moving company's landing pages. A page for Denver should include:
{
"city": "Denver",
"state": "Colorado",
"averagePrice": 2450,
"estimatedDays": 3,
"licensedMoversCount": 127,
"topDestinations": [
{ "destination": "Boulder", "distance": "30 miles", "avgPrice": 1200 },
{ "destination": "Fort Collins", "distance": "65 miles", "avgPrice": 1850 }
],
"faqs": [
{
"question": "Do you move pianos in Denver?",
"answer": "Yes, we have specialized equipment and trained staff for piano moves. Average cost is $1,500–$2,500 depending on access."
}
],
"seasonalPricing": {
"summer": 1.25,
"winter": 0.85
}
}That data comes from your actual business—your pricing engine, your logistics database, your support tickets. A page generated from this JSON solves a real query: "How much does it cost to move to Denver?" Not because you optimized it for the keyword, but because the data is genuine.
Strong templates — uniqueness at scale
Template uniqueness is the second pillar. You need a system that generates genuinely different titles, introductions, and structured data for each page. Not "Best plumbers in [City]" repeated 500 times.
I use a template engine that mixes data-driven and rule-based generation. Here's a simplified approach:
// Title generation based on local data
const generateTitle = (data) => {
const templates = [
`Professional movers in ${data.city}, ${data.state} | Licensed & Insured`,
`Moving companies in ${data.city} – Affordable rates & free quotes`,
`${data.city} movers specializing in ${data.topService}`
];
// Pick template based on data richness
if (data.licensedMoversCount > 100) {
return templates[0];
}
return templates[Math.floor(Math.random() * templates.length)];
};
// Intro generation from actual statistics
const generateIntro = (data) => {
return `
<p>Moving to ${data.city}? We've helped ${data.totalMovesThisYear}
families relocate across Colorado. Our average cost is $${data.averagePrice},
and most moves complete in ${data.estimatedDays} days.</p>
`;
};The key here: every variable is real data. The title changes because your pricing or service mix differs. The intro mentions actual move counts, not a generic statistic. Schema markup (structured data) gets populated from your database, not hardcoded.
For structured data, generate a full Schema.org object for each page:
{
"@context": "https://schema.org",
"@type": "LocalBusiness",
"name": "Movers in Denver",
"areaServed": "Denver, Colorado",
"priceRange": `$${data.minPrice}-$${data.maxPrice}`,
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": data.averageRating,
"reviewCount": data.reviewCount
},
"service": data.topServices.map(s => ({
"@type": "Service",
"name": s.name,
"price": s.averagePrice
}))
}This structured data tells search engines exactly what you offer in each location. It's not generic—it's derived from what your business actually does there.
Internal linking — architecture that matters
Programmatic SEO pages live or die by their linking structure. You have two layers:
- Hub pages (state, region, country level) link to all service area pages
- Service area pages link to nearby areas and back to the hub
This creates topical relevance. If I'm on the Denver movers page, I see links to nearby areas like Boulder and Fort Collins. Each of those pages links back to the Colorado movers hub. Google sees a coherent structure—not 500 orphaned pages.
Build this programmatically:
const buildInternalLinks = (currentCity, allCities) => {
// Hub link (always)
const hubLink = `<a href="/movers">Back to all areas</a>`;
// Nearby areas (using coordinates or distance calculation)
const nearby = allCities
.filter(c => c.state === currentCity.state && c.id !== currentCity.id)
.sort((a, b) => calculateDistance(currentCity.coords, a.coords))
.slice(0, 5); // Link to 5 nearest cities
const nearbyLinks = nearby.map(city =>
`<a href="/movers/${city.slug}">Movers in ${city.name}</a>`
).join(', ');
return {
hubLink,
nearbyLinks,
relatedServicesLinks: buildServiceLinks(currentCity)
};
};You want about 3–7 internal links per page, all contextually relevant. A page should link up (to hub), across (to nearby areas), and sideways (to related services in that area). Not randomly, but because users would find those links genuinely useful.
Quality gates — only publish pages worth ranking
This is where discipline separates successful programmatic SEO from the noise. Define a minimum data threshold before you publish a page. If a city doesn't meet your criteria, don't publish it.
Example gates:
- Data completeness: Page requires pricing, at least 5 FAQs, and 3+ nearby service areas
- Minimum activity: City has received at least 10 inquiries in the past 90 days
- Content length: Page must have 800+ words of unique content
- Schema validity: Structured data passes schema validation
- Linking readiness: City has 3+ nearby areas to link to (prevents orphan pages)
Implement this as a pre-publication check:
const canPublishPage = (pageData) => {
const checks = {
hasPricing: Boolean(pageData.averagePrice),
hasMinFaqs: pageData.faqs.length >= 5,
hasNearbyAreas: pageData.nearbyAreas.length >= 3,
hasMinActivity: pageData.inquiriesLast90Days >= 10,
contentLengthValid: pageData.contentWordCount >= 800,
schemaValid: validateSchema(pageData.schema),
hasInternalLinks: pageData.internalLinks.length >= 3
};
const passCount = Object.values(checks).filter(Boolean).length;
return passCount >= 6; // Require 6 of 7 checks
};
// Before publishing, audit the page
if (!canPublishPage(pageData)) {
logger.warn(`Skipping page for ${pageData.city}: ${JSON.stringify(checks)}`);
return; // Don't publish
}When I implemented this at a previous startup, we cut our page count from 2,400 to 800. Rankings improved dramatically because every page had substance. Bounce rate dropped. Conversion rate climbed. Fewer, better pages beat thousands of thin pages.
Common mistakes
Mistake 1: Ignoring data gaps. You have pricing for 300 cities but FAQs for only 50. Solution: don't publish without both. Set a minimum bar and stick to it.
Mistake 2: Building pages before the data infrastructure exists. If your CRM doesn't track location-level inquiries or your pricing engine can't return city-specific rates, you're not ready for programmatic SEO. Fix the backend first.
Mistake 3: Over-relying on template text. If 80% of your page is boilerplate ("Contact us today for a free quote"), users and Google will penalize you. Aim for 40% template, 60% data-driven unique content.
Mistake 4: Forgetting about maintenance. You publish 1,000 pages and move on. Six months later, pricing changed in 200 cities, but your pages still show old rates. Set up automated audits. Republish pages with stale data.
Mistake 5: Weak internal linking architecture. Linking nearby areas is good. Linking them intelligently (by distance, popularity, or related services) is better. Random linking is worse than no linking.
Key takeaways
- Only generate pages when you have real, location-specific data to back them up. Swapped city names don't rank.
- Build templates that pull from actual business data—pricing, FAQs, local metrics—so each page is genuinely unique.
- Use structured data (Schema.org) programmatically, derived from your database, not hardcoded.
- Create a clear linking hierarchy: hub pages link to all service areas, service area pages link to nearby areas.
- Define quality gates before publishing. If a page doesn't meet your data threshold, don't publish it.
- Automate monitoring and republishing so pages stay accurate as your business evolves.
- Test your approach on a small cohort (50–100 pages) before scaling to thousands.






Comments
Loading comments…