SEO
Programmatic SEO: One Dataset to Thousands of Pages
How I turned public data into pages people could actually find.
13 min readVedas Dixit
My experience with programmatic SEO
Earlier this year, I got the chance to work with a US company that helps low-income families find and claim the benefits they qualify for. I was given a challenge: build a search tool for people using EBT, something people could find on Google and actually use.
If you're not from the US, EBT is the card people use to access their SNAP benefits, commonly known as food stamps. And if you use one, you might have searched for questions like 'Does Walmart take EBT?' or 'Does Costco take EBT?' before heading out to shop.
It turns out the USDA already publishes a public dataset with store locations and other information we could use to answer these questions.
We used that data to build more than 8,000 pages, one for each brand. Here's how we did it.
What programmatic SEO is
Let's say you want to create a page answering whether Costco accepts EBT. You could write the page yourself, add the title and description, and publish it. That's perfectly fine for one page. But what if you wanted to answer the same question for thousands of stores?
Here's what a page like that might look like in React/Next.js:
export const metadata = {
title: "Does Costco take EBT?",
description: "Yes. Costco accepts EBT at 627 locations across 48 states.",
};
export default function Page() {
return (
<main>
<h1>Does Costco take EBT?</h1>
<p>Yes. Costco accepts EBT at 627 locations across 48 states.</p>
</main>
);
}Now imagine creating a separate page like this for every store in the dataset. Writing each one by hand would take forever, and most of the code would be exactly the same. That's where programmatic SEO comes in: you build the page once (like a template), use data to fill in the details, and generate pages for all the stores you want to cover.
export async function generateMetadata({ params }) {
const brand = getBrand(await params);
return {
title: "Does Costco take EBT?",
description: "Yes. Costco accepts EBT at " + brand.count + " locations.",
};
}
export default async function Page({ params }) {
const brand = getBrand(await params);
return (
<main>
<h1>Does Costco take EBT?</h1>
<p>Yes. Costco accepts EBT at {brand.count} locations.</p>
<LocationsTable locations={brand.locations} />
</main>
);
}You've probably come across these pages on websites like Booking.com, Yelp, or Zillow. Search for a hotel in a particular city, a restaurant near you, or a home in a specific neighbourhood, and you'll land on a page built around exactly that search. These sites have too many locations and listings to create every page by hand, so they use templates and data to do much of the work.
That's the basic idea behind programmatic SEO. But generating pages isn't the interesting part. The tough question is whether you have anything useful to put on those pages.
For it to work well, three things usually need to come together:
- People are already searching for the information, with a location, business, product, or another detail changing between searches.
- You can give them a useful answer, not just a page that repeats their search query.
- You have reliable data to back it up, whether that's your own data or a public dataset, & occasonly gets updated/maintained now & then
Think about our EBT example. Someone wants to know whether Costco accepts EBT. Another person wants to know about Walmart. The question barely changes, right? So that's a good starting point for a programmatic SEO journey.
The SPAM problem
But there's a catch. You can also use this approach to flood Google with thousands of useless pages. Google calls this scaled content abuse, and trust me, you don't want to end up on its naughty list 0_0
Scaled content abuse is when many pages are generated for the primary purpose of manipulating search rankings and not helping users.
Google Search Central, spam policies
Goal and gameplan
The USDA, who run SNAP, publish a list of every store that's allowed to take EBT. Name, address, city, state, zip, what kind of store it is, and some other not-so-important stuff, all in one big chunky file that anyone can download (link at the end of the post).
So our goal is to turn that one file into thousands of pages people can find on Google:
SNAP_Retailer_Location_data.geojsonone file, a quarter of a million rows
8,000+
pages, one per brand
Each search should lead to its own page:
- Does Costco take EBT?
/places-that-take-ebt/does-costco-take-ebt - Does Walmart take EBT?
/places-that-take-ebt/does-walmart-take-ebt - Does 7-Eleven take EBT?
/places-that-take-ebt/does-7-eleven-take-ebt - Does Trader Joe's take EBT?
/places-that-take-ebt/does-trader-joes-take-ebt - Does Gelson's Markets take EBT?
/places-that-take-ebt/does-gelsons-markets-take-ebt
The plan was short:
- Download the file.
- Write a script that cleans it up and groups it into brands.
- Build one page template that works for every store.
- Generate the pages and make them discoverable to Google.
Sounds easy enough, right? Spoiler: it was not a SNAP. The tricky part, as usual, was the data, so let's look at the GeoJSON file in detail.
The dataset
Here's a small snippet so you can see what the data looks like:
{
"type": "Feature",
"properties": {
"Record_ID": 1635932,
"Store_Name": "Costco Wholesale 1715",
"Store_Street_Address": "28000 Greenspot Rd",
"Additonal_Address": null,
"City": "Highland",
"State": "CA",
"Zip_Code": "92346",
"Zip4": "4450",
"County": "SAN BERNARDINO",
"Store_Type": "Super Store",
"Latitude": 34.108521,
"Longitude": -117.19322,
"Incentive_Program": null,
"Grantee_Name": null,
"ObjectId": 1437
},
"geometry": { "type": "Point", "coordinates": [-117.19322, 34.108521] }
}The structure is pretty straightforward once you look at an actual entry. Each feature represents a store, with its name, address, city, and other details under properties. The geometry field holds its coordinates, so we know where to place it on a map. That's pretty much it!
We don't need most of those properties (we're not drawing a map), so let's x those out:
Record_ID1635932droppedStore_NameCostco Wholesale 1715keptthe brand name, though it's kinda dirtyStore_Street_Address28000 Greenspot Rdkeptthe store's addressAdditonal_AddressnulldroppedCityHighlandkeptStateCAkeptuseful for the filter on the pageZip_Code92346keptZip44450droppedthe four digits nobody remembersCountySAN BERNARDINOkeptStore_TypeSuper Storekeptthis is gold: grocery | convenience | pharmacyLatitude34.108521keptwhere it isLongitude-117.19322keptIncentive_ProgramnulldroppedGrantee_NamenulldroppedObjectId1437droppedgeometrythe same point againdropped
What's left is a name, an address, and the type of store it is. Looks good enough, especially when the file has nearly a quarter of a million entries, almost half of them convenience stores.
But there were two things I had to figure out before writing the page generation script.
First, the file lists individual stores, not brands. 'Costco Wholesale 1715' is one entry, but people are more likely to search for Costco than for a specific store number (obviously). So I needed a way to group these entries by brand, and that was going to be tricky.
Second, every store in this dataset accepts EBT. There's no column for stores that don't. Keep that in mind, because it matters when we get to the pages themselves.
The script
Before writing any code, we wanted to understand what people actually search for. It's not always 'EBT stores in Texas'. Often, it's a much more specific question: 'Does Walmart take EBT?' or 'Does Target take EBT?' We looked at which stores people search for most and picked a dozen to feature on the tool's homepage: Costco, Aldi, Trader Joe's, Target, Dollar Tree and so on.
That gave us a clear direction: each page needed to answer the question for a specific brand, rather than a city or state. We reflected that in the URL too: /places-that-take-ebt/does-costco-take-ebt. The question itself becomes the address!
The options I had
Next.js has a function called generateStaticParams. You give it a list of slugs and it builds a page for every one of them when you deploy. So the simplest plan was: give it all 8,000 brands and go make a coffee :)
Build Failed :( The coffee was fine, though. Two problems: Vercel has a limit on how big a deployment can be, and 8,000 pages with their location tables were going to blow through what our plan allowed. And honestly, did we need it? Most people search for a few brands. The rest are small chains that might get one visit a month.
So every brand got one of three fates:
200
Built at deploy
The 200 brands with the most locations. Their pages are generated during the build, so they're ready before anyone visits.
WhyThese are the brands people are most likely to search for, so we want their pages ready to go.
7,800+
Built on first visit
The remaining brands are rendered when someone visits for the first time. Next.js then caches the result for subsequent visitors.
WhyThere's little point in spending build time generating thousands of pages that might rarely get visited.
200
Included in the sitemap
We listed only the 200 pre-rendered pages in the sitemap.
WhyWe wanted to start by explicitly listing our priority pages rather than putting every possible URL in the sitemap.
In Next.js, we can control this with generateStaticParams and dynamicParams: one tells Next.js which pages to generate at build time, and the other lets pages outside that list render when requested.
import index from "@/data/brands.json";
// The index is sorted by location count, so the first 200 are the big ones.
const PRERENDER = 200;
export function generateStaticParams() {
return index
.slice(0, PRERENDER)
.map((brand) => ({ slug: `does-${brand.slug}-take-ebt` }));
}
// Every other brand is rendered on its first visit, then cached.
export const dynamicParams = true;generateStaticParams picks the pages to generate during the build. With dynamicParams = true, other valid slugs can still be rendered when someone visits them. Next.js can then cache those pages instead of generating them again for every request.
We didn't need the store data to change on every request, either. Whenever the dataset changes, I can rerun the script and deploy the updated pages. We could automate this later with a scheduled job that fetches the latest dataset and triggers a new deployment through CI/CD. I'll let future me deal with that @_@
One quick note: Next.js 16 handles this differently when Cache Components are enabled. If you're using that setup, the ISR with Cache Components guide (opens in a new tab) explains the updated approach.
From store names to brand files
I ran a script locally on my laptop to turn the giant GeoJSON file into something the website could actually use. The idea is simple: group stores by brand, clean up their data, and save each brand into its own JSON file. Let's build it up piece by piece.
First, we need to group stores by brand. Sounds easy until you look at the actual names in the dataset:
Walmart Supercenter 2145Walmart 1022Walmart 830
becomes Walmart
walmartCostco Wholesale 1715Costco 1267Costco Wholesale 0041
becomes Costco
costco7-Eleven 32622J7-eleven #38597a7-eleven Convenience Store 35397B
becomes 7-Eleven
7-eleven
These are all stores belonging to the same three brands, but the names aren't consistent. If we use them as they are, our script will treat Walmart Supercenter and Walmart as different brands!
I used regex to remove stuff like store numbers, company suffixes and extra words. The last few lines turn the result into a URL-friendly slug, so Costco Wholesale 1715 becomes costco, for example.
// Words that say nothing about which brand it is.
const NOISE = /\b(inc|llc|corp|co|ltd|wholesale|supercenter|superstore|convenience store)\b\.?/gi;
function toBrand(storeName: string) {
return storeName
.replace(NOISE, "")
.replace(/\s*#?\s*\d+[a-z]?$/i, "") // the store number at the end
.replace(/['’]/g, "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-") // "trader joes" becomes "trader-joes"
.replace(/^-|-$/g, "");
}Now we can loop through the stores and group them using that cleaned-up name as the key. Each key points to an array of stores, so all the Costcos end up together, all the Walmarts end up together, and so on.
const file = JSON.parse(
readFileSync("SNAP_Retailer_Location_data.geojson", "utf-8")
);
const brands = new Map<string, Store[]>();
for (const { properties: p } of file.features) {
const slug = toBrand(p.Store_Name);
const stores = brands.get(slug) ?? [];
brands.set(slug, [...stores, toStore(p)]);
}With the stores grouped, we can start preparing the actual pages. I don't want to generate a page for a brand that has only one location, so we skip those. I also remove duplicate locations by comparing their address, city, state, and ZIP code.
There's one more detail: what should the brand be called on the page? The first name we encounter might be an odd spelling, so the script picks the most common name in the group instead. That's why a name like RALEY'S might appear in capitals: that's how it shows up most often in the source data.
Once that's done, we write one JSON file per brand and keep an index of all the brands, sorted by location count. The index comes in handy later when we decide which pages to generate at build time.
const index = [];
for (const [slug, stores] of brands) {
if (stores.length < 2) continue;
const locations = dedupe(stores);
const name = mostCommonName(stores);
// One file per brand. Yes, 8,000 of them. No, I did not open them all.
writeFileSync(
`data/brands/${slug}.json`,
JSON.stringify({ slug, name, locations })
);
index.push({ slug, name, count: locations.length });
}
index.sort((a, b) => b.count - a.count);
writeFileSync("data/brands.json", JSON.stringify(index));That's the core of it: clean the names, group the stores, remove duplicates, and write the files. The complete script combines these steps with the helper functions and file handling. It generated more than 8,000 JSON files for the project. And yes, that was one very large commit to scroll past.
What we built
We took a public dataset, cleaned up nearly a quarter of a million store records, grouped them by brand, and turned that data into more than 8,000 pages. Each page answers a specific question and lets people find the actual store locations, check addresses, and open directions in Maps.
The file
251,574
rows, one per store
The script
1
cleans, groups, dedupes
Brand files
8,000+
one per brand
The pages
8,000+
one per brand file
The interesting part wasn't generating thousands of URLs. It was making sure those pages had something useful to offer beyond a yes-or-no answer. We got a working tool out of it, along with a few lessons about data quality, page performance, and the limits of the dataset we started with.
I can't tell you how well those pages perform in Google just yet. I'd need to look at Search Console to know how many are indexed, which searches bring people in, and whether the pages are reaching the people they're meant to help. More pages don't automatically mean more traffic, after all.
Room (more like an arena) for improvement
A senior engineer once told me that perfection is a trap. You should aim for it, but not at the cost of shipping, learning, and improving along the way. And honestly, writing this blog post has made me notice quite a few things I'd do differently today. I think that's a good thing. It means I've learned something since building this, and hopefully, I'll be a little better at it the next time around.
- Brand matching could be better. Some stores end up on separate pages even when they're part of the same chain. One example I found: Casey's General Store and CASEY'S could be grouped together instead of being treated as two different brands.
- Help people find more pages. The homepage only links to around a dozen brands, so there's no easy way to browse the thousands of others. An A to Z list of all the brands would be a simple place to start.
- Make pages with lots of locations lighter. Dollar General alone has around 21,000 locations (that's a lot of dollars, generally). Loading all of them into the browser at once isn't ideal, so I'd look at server-side pagination instead of sending the entire list to the client.
- Take another look at the FAQ markup. I added FAQ structured data, but that doesn't automatically mean Google will show FAQ results in search. I'd revisit whether it still makes sense to keep it.
- Revisit the sitemap. At the moment, it only includes the 200 pages we pre-render at build time. I'd like to look at whether more pages should be included, rather than letting that number be decided by how the site is built.
- Automate the data updates. I mentioned this above as well. For now, updating everything means downloading the dataset, running the script again, and deploying the changes. It would be nice to have a scheduled job handle most of that for us.
There's also one limitation I wouldn't want to gloss over: the dataset only lists stores that accept EBT. That means we can confirm a yes for the stores it contains, but we can't reliably answer no for stores that aren't listed. I'd want another reliable source before claiming the tool can answer both sides of the question.
A few things I'd take away from this
Programmatic SEO gave us a way to turn one public dataset into thousands of useful pages without writing each one by hand. But the template was only a small part of the job. Cleaning the data, matching brands, choosing which pages to generate, and making the results genuinely useful mattered just as much.
If you're thinking of building something similar, my advice would be to start with the data, not the page count. See what it can actually tell you and whether those pages would be useful to someone searching for them. Generating 8,000 pages is the easy part. Making sure they're worth visiting is what really matters. And of course, keep Google's guidelines in mind and build something that genuinely helps people. :)
If you want to go deeper
- Google's spam policies (opens in a new tab). The definition above, with examples. Ten minutes.
- Managing crawl budget on large sites (opens in a new tab). Google's own guide to sites with a lot of pages, and why links beat sitemaps.
- generateStaticParams (opens in a new tab) and ISR with Cache Components (opens in a new tab) in the Next.js docs.
- The USDA SNAP Retailer Locator (opens in a new tab). The dataset, and a form to report a store that's wrong.
- The EBT store finder (opens in a new tab) itself, if you want to see the pages this post is about.