Programmatic SEO
How to generate a thousand pages without generating a thousand doorway pages. The strip-the-entity-name test, per-entity numbers, computed FAQs, honest fallbacks and sibling linking.
6 min read · updated 2026-08-13· last reviewed 2026-07-26
The one test that separates programmatic SEO from doorway pages
The principle
Take a page from the tier. Delete every occurrence of the entity name. Read what is left.
If what remains is identical to what remains on any other page in the tier, you have doorway pages. Publishing them risks the whole domain rather than just the tier, because doorway classification is applied at the site level.
If what remains is still a substantially different page, because the numbers differ, the FAQs answer different questions, and the related links point somewhere else, you have programmatic SEO.
That is the entire test. It takes two minutes and it is the only thing standing between a legitimate scaled content strategy and a manual action.
Applying it
Run it on three pages, not one. A single page can look unique because it happens to be the one you built the template around. Three pages from different parts of the entity distribution, including one with sparse source data, tells you the truth.
Run it again quarterly. Content decays: a tier that passed at launch can fail eighteen months later because the source data stopped updating and every page now falls back to the same generic text.
Numbers are the cheapest genuine uniqueness
The principle
Prose can be spun. Numbers computed from an entity's own data cannot, and they are what makes a page quotable by an answer engine.
A page carrying six figures derived from its own entity cannot be the same page as its sibling, and no amount of template detection will say otherwise, because the difference is real.
The implementation
// kit/pseo/facts.ts
export type EntityFacts = {
total: number;
medianValue: number | null;
rankWithinParent: number;
percentileWithinParent: number;
changeVsPreviousPeriod: number | null;
updatedAt: string;
};
export function computeFacts(entity: Entity, siblings: Entity[]): EntityFacts {
const ranked = [...siblings].sort((a, b) => b.total - a.total);
const rank = ranked.findIndex((candidate) => candidate.id === entity.id) + 1;
return {
total: entity.total,
medianValue: median(entity.values),
rankWithinParent: rank,
percentileWithinParent: Math.round((1 - rank / ranked.length) * 100),
changeVsPreviousPeriod: entity.previousTotal === null
? null
: entity.total - entity.previousTotal,
updatedAt: entity.updatedAt,
};
}Ranks and percentiles are especially good, because they are relational: they cannot exist on a page that does not know about its siblings, which is exactly the property a template-detector is looking for.
FAQs computed, not templated
The principle
An FAQ answer produced by substituting a name into a sentence is recognisably generated, and it is not quotable, because the answer contains no information specific to the question.
An FAQ answer containing a real figure is quotable, and being quoted by an answer engine is now a meaningful share of the return on a programmatic tier.
The implementation
export function buildFaqs(entity: Entity, facts: EntityFacts): FaqEntry[] {
const faqs: FaqEntry[] = [
{
question: `How many records does ${entity.name} have?`,
answer: `${entity.name} has ${facts.total.toLocaleString("en-US")} tracked records as of ${formatDate(facts.updatedAt)}, which ranks it ${ordinal(facts.rankWithinParent)} of ${entity.siblingCount.toLocaleString("en-US")}.`,
},
];
if (facts.medianValue !== null) {
faqs.push({
question: `What is the median value in ${entity.name}?`,
answer: `The median value in ${entity.name} is ${formatCurrency(facts.medianValue)}, based on ${facts.total.toLocaleString("en-US")} records as of ${formatDate(facts.updatedAt)}.`,
});
}
return faqs;
}Note the conditional. A question is only asked when there is a real answer, so a sparse entity gets three FAQs instead of six rather than six FAQs with three empty answers.
Two details that bite:
export function ordinal(n: number): string {
const remainderHundred = n % 100;
// 11th, 12th, 13th are exceptions to the 1st/2nd/3rd rule.
if (remainderHundred >= 11 && remainderHundred <= 13) return `${n}th`;
switch (n % 10) {
case 1: return `${n}st`;
case 2: return `${n}nd`;
case 3: return `${n}rd`;
default: return `${n}th`;
}
}And date parsing. A bare YYYY-MM-DD passed through a local-timezone parser lands on the previous day for anyone west of UTC, which produces a page dated one day earlier than the sitemap says it changed.
Honest fallbacks
The principle
A template that prints 0 or a dash when data is missing is asserting something false. Labelling the gap is accurate, and it turns out to be a trust signal: a page that says "we have no data for this yet" reads as a real dataset, and a page that says "0" reads as a broken one.
The implementation
{facts.medianValue === null ? (
<p className="text-fg-muted">
No median is published for {entity.name} because fewer than five records are
available. This page updates when the threshold is met.
</p>
) : (
<StatTile label="Median value" value={formatCurrency(facts.medianValue)} />
)}Sibling linking
The principle
A flat tier of 1,689 pages linked only from a paginated index gets crawled once and then rarely. Sibling links turn a list into a connected graph a crawler can traverse without returning to the hub.
The pattern: each page links up to its parent, sideways to six or so siblings chosen by a stable rule, and across to related content in other parts of the site.
The implementation
Choose siblings deterministically, so the link graph is stable across builds:
export function selectSiblings<T extends { id: string; slug: string; name: string }>(
current: T, all: T[], count = 6,
): T[] {
const others = all.filter((item) => item.id !== current.id);
const start = hashToIndex(current.id, others.length);
// Wrap around from a hashed offset, so every page is linked from roughly the
// same number of other pages rather than a few hubs absorbing all the links.
return Array.from({ length: Math.min(count, others.length) }, (_, i) => others[(start + i) % others.length]!);
}A random selection per build produces a different link graph on every deploy, which looks like instability. A hash of the entity id is deterministic and still distributes links evenly.
Checks this chapter covers
Each one has a command you can run against your own site.
- →Apply the strip-the-entity-name test before publishing a tierpseo-strip-the-name-test
- →Each page carries numbers computed from its own entitypseo-per-entity-numbers
- →FAQs are computed per entity, not templated with a name substitutionpseo-computed-faqs
- →Missing data renders an honest label, never a fabricated valuepseo-honest-fallbacks
- →Each page links up to its hub, sideways to siblings, and across to related contentpseo-sibling-links
- →Generated data is committed as typed source, with a stamped update timepseo-snapshot-generation
- →Every programmatic URL is prerendered, not rendered on demandpseo-static-generation