Skip to main content
ASoc
Tutorial

Programmatic SEO in Next.js: Derive the Pages, Don't List Them

A hand-maintained slug list left 24 of our products on no category page at all, and nothing failed. Here is the derived hub-and-spoke build that fixed it.

The ASoc Team13 min read

Generate the pages from your data, and generate their membership from the same data. A hand-written list of which items appear on which page is the failure mode nobody catches: ours silently stopped covering new products as the catalog grew, and 24 of 111 products ended up on no category page at all with nothing in the build to say so.

Programmatic SEO is usually taught as page generation — generateStaticParams, a template, a data source, done. That part is easy and it is not where the failures are. The failures are in the link graph the generated pages are supposed to create, and they are silent by construction: a page that exists and is not linked looks exactly like a page that is, until you read Search Console.

The architecture: distinct terms per tier

The point of a programmatic tier is not more pages. It is giving every leaf page a crawlable parent that is genuinely about it. Ours looks like this:

/                              → "tailwind css templates"        [hub]
  └─ /tailwind-admin-template  → "tailwind admin template"       [mid hub]
       ├─ /react-admin-template   → "react admin template"       [spoke]
       └─ /nextjs-admin-template  → "nextjs admin template"      [spoke]
            └─ /templates/<slug>  → the product pages            [leaf]

Each tier targets a distinct term. That is the whole cannibalization defence: the home page owns the broadest query, the mid hub owns the category, the spokes own framework-plus-category, and the leaves own product names. No two pages compete for the same search, so authority flows down instead of splitting sideways.

Note the URL shape. Root-level segments, not subdomains, and not nested under /templates/. Product URLs stay flat at /templates/<slug>; the category keywords live on the hub tier. Mixing those — putting the category keyword in the product URL — is how you end up with two pages fighting over one query.

The mistake: listing membership by hand

Our first version of this tier had a productSlugs array on each page:

// Don't do this.
{
  slug: "nextjs-admin-template",
  productSlugs: ["asoc-admin", "asoc-lura-admin", "asoc-apex-admin"],
}

It is the obvious implementation, it is explicit, and it was correct on the day it was written. That is the trap. The array is a snapshot taken when the catalog was smaller, and a snapshot has no way to notice the world moving.

The catalog grew to 111 products. Nobody updates seven slug arrays when they onboard a template — the onboarding checklist did not mention them, because when it was written the arrays were complete. By the time we audited, 24 available products were listed on no spoke at all. They existed, they were in the sitemap, and the only page linking to them was a single flat 111-item grid.

Nothing failed. No test broke, no build warned, no page 404'd. The pages were perfect and orphaned.

The fix: derive membership from the data

Replace the list with a query. The page declares what it is about; the code works out which items match:

export function frameworkLandingProducts(
  content: FrameworkLandingContent,
): TemplateProduct[] {
  return catalog.filter(
    (product) =>
      product.category === content.category &&
      product.editions.some(
        (edition) =>
          edition.status === "ready" &&
          (content.framework === null ||
            edition.framework === content.framework),
      ),
  );
}

Two axes, one predicate. The page config carries a category and a framework (with null meaning the umbrella page that ignores framework), and membership falls out of the catalog. Onboard a template and it joins its hubs on the next build, with no checklist step and no chance of being forgotten.

The edition.status === "ready" clause is doing quiet but important work: a product with a Vue edition still in progress does not appear on a Vue page. The page advertises only what it can actually deliver, which is the difference between a hub and a doorway.

Two consequences worth stating, because they surprised us:

Counts stop being configuration and become description. "The Next.js admin hub lists 3 products" is now an observation about the catalog, not a number anyone maintains. Documentation that states counts should say so, or it will be wrong within a month.

You can only build a page whose query returns something. Which is the next section.

The thin-page guardrail

The temptation with derived pages is to generate the full matrix — every category × every framework — because the code makes it free. Do not.

We have no /react-shop-template page. Every shop and landing product in the catalog currently ships a Next.js edition only, so that page's query returns zero products, and a category page with no products on it is a doorway page: a URL that exists to hold a keyword and delivers nothing. Google has been explicit about this for years, and it is the single most common way a programmatic tier turns into a liability.

The rule we hold to: do not build the page ahead of the inventory. When a shop template ships a React edition, the spoke becomes worth building, and not before. This is enforceable rather than aspirational — see the tests below.

Beyond having real items on it, a generated page needs a reason to exist as a page:

  • Unique intro copy per page, written by a person, not a sentence with the category name interpolated into it.
  • A "why this framework/category" section that says something true and specific about that combination.
  • A real FAQ — questions people actually ask about that term, with answers, marked up as FAQPage JSON-LD.
  • BreadcrumbList JSON-LD matching the path a visitor can actually walk.
  • Text-forward layout. Ours deliberately have no hero imagery, which makes them read as intentional reference pages rather than as landing pages built to catch a query.

If you strip out the interpolated data and two pages read identically, you have variables, not content.

The half everybody skips: the crawl path

Generating the pages is a third of the job. Here is the rest, and this is where the measurable gains were.

Orphans, and how we found ours

Search Console had 38 product pages in "Discovered — currently not indexed" — known to Google, never crawled. That status is the specific symptom of a link graph problem, not a content problem. The pages were fine; nothing pointed at them except a flat grid.

Worse, our hub tier was noindex at the time, on the reasoning that it was not ready to rank. That is the worst of both worlds: the hub tier was the only layer between the flat grid and the products, and Google eventually treats a long-lived noindex page as nofollow too. The tier built to funnel crawl equity into the products was withholding it instead. Flipping those seven pages to indexable mattered more than anything we did to their content.

Each product page now closes with a rail of six same-category siblings. The selection rule matters more than it looks:

The 6 are a sliding window over catalog order, wrapping at the end — that is what guarantees every product receives exactly 6 inbound sibling links.

A "6 newest" or "6 random" rail would have left the tail of the catalog exactly as orphaned as before, which is what put those 38 pages in the report in the first place. A wrapping window is the only cheap selection that guarantees uniform in-degree. If your rail is "related products by score", check the resulting in-degree distribution — the long tail will be at zero.

Your sitemap dates are a claim Google can check

The default lastModified: new Date() stamps the build time on every URL. Our sitemap carries 171 of them — 11 static routes, 7 hubs, 111 products, 42 posts. Deploying weekly, that default tells Google all 171 pages changed today, every week, which is false and falsifiable: the crawler has the previous version.

function productLastModified(product: TemplateProduct): Date {
  const newest = product.changelog[0]?.date;
  return newest ? new Date(`${newest}T00:00:00Z`) : new Date();
}

Product URLs carry their newest changelog date. Hub URLs carry the newest date among the products they list — which is honest, because the page's content is that list. Posts carry their publish or updated date. changeFrequency on products went from weekly to monthly for the same reason.

This is not pedantry about a field Google says it may ignore. It is about the field being used to schedule crawls, and a source that lies stops being consulted — which is precisely the scheduling behaviour that leaves pages "Discovered — currently not indexed".

The product breadcrumb is Home → Templates → category hub → product, which is the route the sibling rail's "Browse all" link actually walks. A breadcrumb describing a hierarchy your navigation does not implement is a structured-data claim contradicted by the page.

Make the invariants tests, because humans will not check

Everything above degrades silently. The only defence is a test suite that fails the build, and these are cheap to write because the data is already typed:

TestCatches
Every available item appears on ≥1 hubThe 24-orphan bug, on the day it recurs
No hub is emptyDoorway pages from an over-generated matrix
Hubs only advertise items shipping what they claimA Vue page listing products with no Vue edition
Every category with items has an umbrella hubA category with no parent page
The sitemap lists every hub and every itemSitemap and routes drifting apart
No duplicate URLs in the sitemapTwo tiers claiming one URL
No item URL carries the build-clock dateThe lastModified regression, quietly reintroduced

That last pair is the one to copy if you copy nothing else. Our sitemap and our indexable-page set drifted apart exactly once — the spokes were noindex and absent from the sitemap — and that is a hard thing to notice from either file alone, because each looks internally consistent.

Mistakes and how they show up

MistakeSymptomFix
Hand-listing which items appear on which pageNew items silently on no pageDerive membership with a predicate
Generating the full category × framework matrixThin pages, doorway-page riskOnly build a page whose query returns items
Same template, interpolated variablePages do not rank, or get filteredUnique copy, FAQ and rationale per page
noindex on the hub tier "until it is ready"Products stop being crawled at allLong-lived noindex behaves like nofollow
lastModified: new Date()Crawl scheduling degrades sitewideUse a real per-item date
"Related items" rail chosen by score or recencyThe tail of the catalog stays orphanedWrapping sliding window — uniform in-degree
Category keyword in the leaf URLLeaf and hub compete for one queryOne term per tier
Indexable facet/filter URLsCrawl budget burned on combinationsDecide which facets index — see the filtering post
No tests on the link graphEverything above, again, in six monthsThe table in the previous section

Frequently asked questions

How many programmatic pages is too many? Wrong axis. The limit is not a count, it is whether each page has enough distinct content and enough real inventory to be worth a visit. Seven pages backed by 110 products is a healthy tier. Seven hundred pages backed by the same 110 is a doorway network. Grow the tier when the inventory grows, not when the traffic target does.

Should programmatic pages be statically generated? Yes, if the data changes on your deploy cadence rather than per request. Ours are all prerendered. The thing to watch is the features that quietly opt a page out of static rendering without erroring — that is its own post.

Do I need JSON-LD on these pages? Breadcrumb and FAQ markup are worth it and cheap, because you already have the data in typed form. They are not what makes the page rank; they make the result look like a reference page in the SERP. Validate them in Google's Rich Results Test once per template, not once per page.

We already have thousands of these pages and they are not indexed. Where do I start? Not with the content. Export the URL list, work out the in-degree of each page from your own internal links, and look at the zero and one buckets. In our case, fixing membership and adding the sibling rail addressed the problem that no amount of copy editing would have touched.

Is this different from a migration? Yes, and they are often confused. This is building a tier that did not exist. Moving an existing site's URLs is a different discipline with a different failure mode — that one is migrating without losing rankings.

Where this pattern earns its keep

A services business with real inventory — coverage types, portfolio strategies, analytical capabilities — has the same structure this post describes: many leaf pages that need category parents and a link graph that keeps them reachable. The templates below start from a typed content model rather than hand-written pages, which is the precondition for deriving any of it.

Keep reading

Tutorial8 min read

React Carousel: Two Patterns, Zero Dependencies, One Bug We Fixed

A passive CSS marquee and a navigable translated-track gallery, both dependency-free — plus the missing prefers-reduced-motion guard this audit found and fixed.

Read more