# Programmatic SEO

> Pages generated from your listings, best-of per category and tag and alternatives per listing. Switch them on, read the build line, keep them from going thin.

You are reading one page of the ZeroDirs documentation. ZeroDirs is a paid Astro + Cloudflare Workers starter for directory sites: every page is rendered to static HTML at build time, the site ships no client JavaScript outside `/search/`, and one file — `site.config.ts` — carries roughly 80% of the customisation.

Two things to hold on to before you act on anything below:

- `site.config.ts` is validated by a zod schema with ten cross-field rules. After any edit to it, run `pnpm check:config`; every problem is reported as `site.config.ts › <path>: <message>` and the whole file is checked at once.
- The repository ships its own `AGENTS.md` with twenty hard rules, and a machine-checked test suite behind them. If you are working inside a ZeroDirs project, read that file first — it overrides anything general you infer from this page.

Source: https://zerodirs.com/docs/configure/pseo/

---

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.

> **Free edition**
>
> Not in the free edition: `features.pseo` is fixed to `false` there and the generator is not
> shipped, so none of these URLs exist and listing pages have no "Appears in" block.

## Setup

1. Add or edit an entry in `pseo.templates`. The `id` picks the pattern; the copy is yours.

   ```ts title="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:config` answers `site.config.ts OK — <name> (<url>); …`. An unknown placeholder or
   a repeated `id` fails here, naming the field.

2. Build and read the generator's line.

   ```sh title="Terminal"
   pnpm build
   ```

   ```text
   pseo: generated N (dropped by minItems M, truncated K)
   ```

   `N` is the page count. `M` is how many candidates had fewer listings than their `minItems`.
   `K` is non-zero only above `pseo.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.

3. Open one page: `https://<your domain>/best-<category>-for-<tag>/` after a deploy, or
   `dist/client/best-<category>-for-<tag>/index.html` in the build.

   ![The demo's /best-writing-for-long-form/ page: breadcrumb, h1, intro, the item list, the FAQ and the sibling links](../../../assets/screenshots/pseo.png)

4. Check the sitemap chunk. `https://<your domain>/sitemap-pseo-0.xml` lists exactly the generated
   URLs; it is absent while there are none.

## 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

| 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](https://zerodirs.com/docs/configure/reference/#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

| 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](https://zerodirs.com/docs/run/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

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

See [`features`](https://zerodirs.com/docs/configure/reference/#features), [`pseo`](https://zerodirs.com/docs/configure/reference/#pseo) and
[`seo`](https://zerodirs.com/docs/configure/reference/#seo).

## Files

- src/
  - features/
    - index.ts the facade: no pages when either switch is off
    - paid/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
  - components/pseo/ the intro, list, FAQ and siblings blocks
  - content/pseo/ one Markdown file per page you override
- site.config.ts the demo's templates

## Related

- [SEO and the report](https://zerodirs.com/docs/configure/seo/) — Titles, sitemap, JSON-LD, internal links and the report that counts these pages.
- [OG images](https://zerodirs.com/docs/configure/og-images/) — Generated pages use the default image unless seo.ogScope.pseo is on.
- [Cost and limits](https://zerodirs.com/docs/run/cost-and-limits/) — Every generated page is a file against the plan limit.
- [New pSEO template](https://zerodirs.com/docs/agents/recipes/new-pseo-template/) — The recipe for a pattern that does not exist yet, such as X vs Y.
