SEO

Programmatic SEO: One Dataset to Thousands of Pages

How I turned public data into pages people could actually find.

13 min read

One dataset, thousands of pages: a short film that is the fast version of the post below. One public file of every store that takes EBT becomes a map of dots; someone asks Google whether Costco takes EBT; one store's row is cleaned, stores are grouped into brands, one page template is filled for every brand until there are 8,000 pages; the biggest 200 are built before anyone visits and the rest on first visit; a grid of empty pages is struck out as spam; and the question on the phone lands on a page that already has the answer.

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:

app/does-costco-take-ebt/page.tsx
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.

app/places-that-take-ebt/[slug]/page.tsx
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.geojson

one file, a quarter of a million rows

8,000+

pages, one per brand

One file, one file, a quarter of a million rows, becoming 8,000+ pages, one per brand.

Each search should lead to its own page:

does Costco take ebt

  1. Does Costco take EBT?/places-that-take-ebt/does-costco-take-ebt
  2. Does Walmart take EBT?/places-that-take-ebt/does-walmart-take-ebt
  3. Does 7-Eleven take EBT?/places-that-take-ebt/does-7-eleven-take-ebt
  4. Does Trader Joe's take EBT?/places-that-take-ebt/does-trader-joes-take-ebt
  5. Does Gelson's Markets take EBT?/places-that-take-ebt/does-gelsons-markets-take-ebt
A Google search for the question, with the brand swapping, and the page each version becomes.

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:

SNAP_Retailer_Location_data.geojson
{
  "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_ID1635932dropped
  • Store_NameCostco Wholesale 1715keptthe brand name, though it's kinda dirty
  • Store_Street_Address28000 Greenspot Rdkeptthe store's address
  • Additonal_Addressnulldropped
  • CityHighlandkept
  • StateCAkeptuseful for the filter on the page
  • Zip_Code92346kept
  • Zip44450droppedthe four digits nobody remembers
  • CountySAN BERNARDINOkept
  • Store_TypeSuper Storekeptthis is gold: grocery | convenience | pharmacy
  • Latitude34.108521keptwhere it is
  • Longitude-117.19322kept
  • Incentive_Programnulldropped
  • Grantee_Namenulldropped
  • ObjectId1437dropped
  • geometrythe 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:

  1. 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.

  2. 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.

  3. 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.

app/places-that-take-ebt/[slug]/page.tsx
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 2145
    • Walmart 1022
    • Walmart 830

    becomes Walmartwalmart

    • Costco Wholesale 1715
    • Costco 1267
    • Costco Wholesale 0041

    becomes Costcocostco

    • 7-Eleven 32622J
    • 7-eleven #38597a
    • 7-eleven Convenience Store 35397B

    becomes 7-Eleven7-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.

scripts/build-brand-files.ts
// 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.

scripts/build-brand-files.ts
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.

scripts/build-brand-files.ts
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

251,574 rows, one per store, through one script (build-brand-files.ts) that cleans, groups, dedupes, into 8,000+ one per brand, each becoming a page like "Does Costco take EBT?" with the answer and the store list.

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