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.
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
FAQPageJSON-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.
Every leaf needs inbound links from its siblings
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".
Breadcrumbs should match a path that exists
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:
| Test | Catches |
|---|---|
| Every available item appears on ≥1 hub | The 24-orphan bug, on the day it recurs |
| No hub is empty | Doorway pages from an over-generated matrix |
| Hubs only advertise items shipping what they claim | A Vue page listing products with no Vue edition |
| Every category with items has an umbrella hub | A category with no parent page |
| The sitemap lists every hub and every item | Sitemap and routes drifting apart |
| No duplicate URLs in the sitemap | Two tiers claiming one URL |
| No item URL carries the build-clock date | The 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
| Mistake | Symptom | Fix |
|---|---|---|
| Hand-listing which items appear on which page | New items silently on no page | Derive membership with a predicate |
| Generating the full category × framework matrix | Thin pages, doorway-page risk | Only build a page whose query returns items |
| Same template, interpolated variable | Pages do not rank, or get filtered | Unique copy, FAQ and rationale per page |
noindex on the hub tier "until it is ready" | Products stop being crawled at all | Long-lived noindex behaves like nofollow |
lastModified: new Date() | Crawl scheduling degrades sitewide | Use a real per-item date |
| "Related items" rail chosen by score or recency | The tail of the catalog stays orphaned | Wrapping sliding window — uniform in-degree |
| Category keyword in the leaf URL | Leaf and hub compete for one query | One term per tier |
| Indexable facet/filter URLs | Crawl budget burned on combinations | Decide which facets index — see the filtering post |
| No tests on the link graph | Everything above, again, in six months | The 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.
