Programmatic SEO
Programmatic SEO builds pages out of the listings you already have: /best-{category}-for-{tag}/
for every category and tag pair with enough listings, /{name}-alternatives/ for every listing,
and, off until you enable it, /free-{category}-{nouns}/. Two switches gate it, features.pseo
and pseo.enabled, both true by default; the copy is pseo.templates in site.config.ts, which
the demo fills with two entries and a fresh config leaves empty.
-
Add or edit an entry in
pseo.templates. Theidpicks the pattern; the copy is yours.site.config.ts pseo: {enabled: true,maxPages: 2000,templates: [{id: 'best-category-for-tag',enabled: true,minItems: 3,maxItems: 24,title: 'Best {category} {nouns} for {tag} ({year})',description: 'The {count} best {category} {nouns} for {tag}, compared on pricing, platforms and features. Updated {year}.',h1: 'Best {category} {nouns} for {tag}',intro: 'Looking for {category} {nouns} built for {tag}? We compared {count} of them, starting with {topNames}.\n\nEvery {noun} here is listed in our {category} category and tagged {tag}.',faq: [{ q: 'How were these {category} {nouns} selected?', a: 'Every {noun} is submitted, reviewed by a human and tagged by hand.' }],},],},pnpm check:configanswerssite.config.ts OK — <name> (<url>); …. An unknown placeholder or a repeatedidfails here, naming the field. -
Build and read the generator’s line.
Terminal pnpm buildpseo: generated N (dropped by minItems M, truncated K)Nis the page count.Mis how many candidates had fewer listings than theirminItems.Kis non-zero only abovepseo.maxPages. A slug that collides with an existing page is named on its own[zerodirs:pseo] skipped "<slug>"line. Near the end,seo-report: … pseo N pages (best-category-for-tag=A, name-alternatives=B)counts the same pages from the rendered output. -
Open one page:
https://<your domain>/best-<category>-for-<tag>/after a deploy, ordist/client/best-<category>-for-<tag>/index.htmlin the build.
-
Check the sitemap chunk.
https://<your domain>/sitemap-pseo-0.xmllists exactly the generated URLs; it is absent while there are none.
Verify
Section titled “Verify”| Where | You should see | |
|---|---|---|
| The count | the build output | pseo: generated N (dropped by minItems M, truncated K) with N above 0 |
| A page | https://<your domain>/best-<category>-for-<tag>/ |
your h1, the intro, one card per listing, the FAQ, “Related guides” |
| The other direction | any listing page | “Appears in”, linking the generated pages that list it |
| The sitemap | https://<your domain>/sitemap-pseo-0.xml |
one <loc> per generated page |
| Coverage | the build output | seo-report: categories without pSEO coverage: none |
How it works
Section titled “How it works”| Template id | URL | One page per | minItems default |
Ships enabled |
|---|---|---|---|---|
best-category-for-tag |
/best-{category}-for-{tag}/ |
category and tag pair: the listings in that category carrying that tag | 3 | yes |
name-alternatives |
/{name}-alternatives/ |
listing: the same ranking as its related block, minus itself | 2 | yes |
free-category |
/free-{category}-{nouns}/ |
category: its listings priced free |
3 | no |
The generator runs at build time, template by template in config order. A candidate with fewer
listings than the template’s minItems is dropped and counted. A slug that collides with a
reserved segment, a route base, a category, a tag or a listing is skipped with a warning; two
templates producing the same slug keep the first. Survivors are sorted by listing count, largest
first, and cut to pseo.maxPages. A page shows at most maxItems listings — featured first, then
by popularity; alternatives keep their ranking — and never more than 24, the ceiling the ItemList
schema and the link budget assume; a larger value is clamped and reported.
Every page links sideways under “Related guides”. A category-and-tag page gets up to six pages of
the same category with other tags and six of the same tag in other categories; an alternatives
page gets the alternatives pages of its listed items and the best-of pages of its category, six
each. The template holds a page to 30 internal links inside <main>: the breadcrumb, the cards and
the “More” link come first, and the siblings take what is left.
Copy is interpolated from the SEO placeholders plus {pricing} and {topNames}, the first three
names — see Registries. intro is plain text: a blank line
starts a paragraph, and no Markdown is rendered. To rewrite one page by hand, add
src/content/pseo/<slug>.md: its body replaces the intro, and a title or description in its
frontmatter replaces the meta. The FAQ is visible copy only; no FAQPage schema is emitted.
When it is off or degraded
Section titled “When it is off or degraded”| Config state | The visitor sees | /admin/ reports |
Fix |
|---|---|---|---|
features.pseo: false |
no generated pages and no “Appears in”; the report says pseo 0 pages (disabled) |
nothing | set it to true |
pseo.enabled: false |
the same | nothing | set it to true |
one template enabled: false |
that template’s pages are gone, the others stay | nothing | set it to true, or delete the entry |
minItems higher than the data supports |
few or no pages; dropped by minItems M is large and seo-report: categories without pSEO coverage: names categories |
nothing | lower minItems, or tag more listings |
| the file budget | above 80% of the plan limit the build warns; above 90% it fails with Cut files (seo.ogScope, fewer pSEO templates, fewer listings) |
nothing | fewer templates, seo.ogScope.pseo: false; see Cost and limits |
| the free edition | no generated pages | nothing | the paid starter |
/admin/ reports nothing for any of these: generation has no runtime.
Configuration
Section titled “Configuration”| Field | Default | What it changes |
|---|---|---|
features.pseo |
true |
the feature switch |
pseo.enabled |
true |
the second switch; the template list can stay in the file while off |
pseo.maxPages |
2000 |
the cap, applied after sorting by size |
pseo.templates[] |
[]; two in the demo |
id, enabled, minItems, maxItems, title, description, h1, intro, faq[] |
seo.templates.pseo |
'{title}' and '{title} — {count} {nouns} compared, updated for {year}.' |
the title and meta description wrapper |
seo.ogScope.pseo |
false |
one generated OG image per page |
Directorysrc/
Directoryfeatures/
- index.ts the facade: no pages when either switch is off
Directorypaid/pseo/
- patterns.ts the patterns and their slug shapes
- generate.ts
minItems, collisions, ordering,maxPages, siblings - copy.ts the placeholders and the plain-text intro
- templates/PseoPage.astro the page: breadcrumbs, list, FAQ, siblings, JSON-LD, the link budget
Directorycomponents/pseo/ the intro, list, FAQ and siblings blocks
- …
Directorycontent/pseo/ one Markdown file per page you override
- …
- site.config.ts the demo’s templates