# SEO and the report

> Titles, canonicals, sitemap, robots.txt, JSON-LD and internal links on every page, and the build report that checks them and fails on an orphan.

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

---

SEO is the most oversold part of any directory starter, so first: what is promised, and what is not.

**Promised.** A title and description on every page from a template you control, cut at word
boundaries. A self-referencing canonical, including on `/page/2/`. A sitemap split by page kind. A
`robots.txt` that disallows every dynamic route. JSON-LD chosen from `site.kind`, escaped so a
submitted name cannot break out of the script tag. Thin category and tag pages go `noindex` and
leave the sitemap on their own.

**Not promised.** No rich results for ratings: with no review data, `aggregateRating` and `review`
are never emitted, and a test enforces it. No FAQ rich results: the FAQ on generated pages is
visible copy with no `FAQPage` schema. No sitelinks search box.

> **Free edition**
>
> No build report, no generated pages, no related block; `seo.strictLinks` is fixed to `false`.

## Titles and descriptions

`seo.templates` holds a `titleTemplate` wrapper and a title and description pair for `home`,
`listing`, `category`, `tag`, `pseo` and `blog`. Override a pair; the rest keep their defaults.

```ts title="site.config.ts"
seo: {
  templates: {
    titleTemplate: '{title} | {siteName}',
    category: {
      title: 'Best {category} {nouns} ({count})',
      description: 'Compare {count} {category} {nouns}, hand-picked and updated for {year}.',
    },
  },
},
```

The placeholders are `{title}`, `{siteName}`, `{name}`, `{tagline}`, `{category}`, `{tag}`,
`{count}`, `{noun}`, `{nouns}` and `{year}`; the last two come from `routes.listingNoun`. Anything
else fails `pnpm check:config` with `unknown placeholder {x}`.

A title is the `titleTemplate` result when that is 60 characters or fewer, else the bare page title
when that is 65 or fewer, else the page title cut at a word boundary to 65. A description is cut at
a word boundary to 160 characters. Page 2 onwards of a category or tag gets a ` - Page n` suffix.

## Canonicals and trailing slashes

Every canonical is `site.url` plus the page path, and every page path ends in a slash. A paginated
page canonicalises to itself — `/categories/writing/page/2/` points at page 2, never at page 1 —
and there is no `rel="prev"` or `rel="next"`. `site.url` is baked in at build time, with the
sitemap, the feeds and the OG image URLs: set it to your real domain before the first build you
deploy.

## Sitemap and robots

The build writes `sitemap-index.xml` and one chunk per page kind.

| Chunk | Contains | Frequency, priority |
| --- | --- | --- |
| `sitemap-listings-0.xml` | every listing; `lastmod` is the approval date | weekly, 0.8 |
| `sitemap-taxonomy-0.xml` | category and tag pages, their indexes and pagination | weekly, 0.7 |
| `sitemap-pseo-0.xml` | the generated pages, from the exact slug list | weekly, 0.6 |
| `sitemap-blog-0.xml` | `/blog/` and every post; `lastmod` is the updated or publish date | monthly, 0.5 |
| `sitemap-pages-0.xml` | everything else; `/` is daily, 1.0 | plugin defaults |

Dropped: `/search/`, `/404/`, every `noindex` page, anything under a `robots.txt` `Disallow:`
prefix, and file endpoints such as `/rss.xml`.

`/robots.txt` is `User-agent: *`, one `Disallow:` per prefix, then
`Sitemap: <site.url>/sitemap-index.xml`. The prefixes are a fixed list — `/admin/`, `/api/`,
`/_actions/`, `/submit/`, `/search/`, `/login/`, `/account/`, `/auth/` — plus `seo.robots.disallow`
(by default `/out/` and `/submit/status/` on top), plus `/out/` whenever `features.clicks` is
`'redirect'`.

`noindex,follow` goes on `/search/`, `/submit/` and `/admin/` always, and on a category or tag page
with fewer listings than `seo.noindexBelow` (default 2): an empty category is still built, but
noindexed and out of the sitemap.

## Structured data

`seo.jsonLd.listing` picks the listing type: `software` (`Product` plus `SoftwareApplication`),
`product`, `organization`, `localBusiness` (`Organization` unless the listing's `extra` has
`address` and `telephone`), or `none`. Unset, it follows `site.kind` (`software` → `software`;
`service` → `organization`; `business` → `localBusiness`). The home page emits `WebSite` and, under
`jsonLd.organization`, `Organization`. `jsonLd.breadcrumbs` adds a `BreadcrumbList` to listing,
category and generated pages; `jsonLd.itemList` adds an `ItemList` of at most 24 entries to
category, tag and generated pages. Generated pages emit no `FAQPage`.

## Internal links

The related block on a listing page takes every listing sharing a tag or the category, scores each
as 0.6 × tag overlap (Jaccard) + 0.3 × normalised popularity + 0.1 when featured, and keeps the
top `listing.relatedCount` (default 6) by score, then popularity, then slug.
`features.relatedLinks: false` renders nothing there. "Appears in" lists the generated pages that
include the listing, largest first, at most 8.

An orphan is a listing with fewer than 2 other pages linking to it. Under
`seo.strictLinks: true` it fails the build; under `false`, the default, the build warns
`N orphan listing(s); set seo.strictLinks: true to fail the build on this`.

## The build report

The report scans every `index.html` under `dist/client` and prints four lines:

```text
seo-report: scanned N pages; L listings; X noindex; pseo P pages (best-category-for-tag=A, name-alternatives=B)
seo-report: categories without pSEO coverage: none
seo-report: sitemap: N urls expected (blog=…, listings=…, pages=…, pseo=…, taxonomy=…) — dist sitemap matches
seo-report: inbound links per listing: min M; orphans (fewer than 2 inbound): 0 [strictLinks=on]
```

Warnings, never failures: a route with no `index.html`, an indexable page missing from
`sitemap-*.xml`, a sitemap entry the rules should have dropped, and a listing or generated page with
more than 30 internal links inside `<main>`. The one failure is an orphan under
`seo.strictLinks: true`:

```text
seo-report: 1 orphan listing(s) with fewer than 2 inbound links (seo.strictLinks=true): /tools/my-tool/ (1 from /categories/writing/page/2/)
```

Neighbouring lines from other steps: `pseo: generated N (dropped by minItems M, truncated K)`
is the generator's count, see [Programmatic SEO](https://zerodirs.com/docs/configure/pseo/). `budget: N files / 20000 (P%) — free plan, ok` counts every
file in `dist/client` against the plan limit, warns above 80% and fails above 90%, see
[Cost and limits](https://zerodirs.com/docs/run/cost-and-limits/). `zero-js: N content pages, 0 islands, 0 external scripts — ok`
fails when a content page other than `/search/` ships a script.

## Lighthouse

`pnpm lhci` is not part of the build. Run it after `pnpm build`: it reads the route list the build
wrote, audits the first indexable page of each kind five times on the desktop preset, and fails
when the median of any category — performance, accessibility, best practices, SEO — is below 0.95.
`noindex` pages are skipped. Reports stay in `.lighthouseci/`.

## Verify

| | Where | You should see |
| --- | --- | --- |
| Title, canonical, JSON-LD | `view-source:https://<your domain>/<listingBase>/<slug>/` | one `<title>`; a `<link rel="canonical">` on your domain ending in a slash; JSON-LD naming the listing |
| Robots | `https://<your domain>/robots.txt` | `User-agent: *`, the `Disallow:` lines, `Sitemap: https://<your domain>/sitemap-index.xml` |
| Sitemap | `https://<your domain>/sitemap-index.xml` | one `<loc>` per chunk; `sitemap-listings-0.xml` lists your listings |
| The report | the build output | the four `seo-report:` lines, ending `orphans (fewer than 2 inbound): 0` |

## Configuration

| Field | Default | What it changes |
| --- | --- | --- |
| `seo.templates.titleTemplate`, `seo.templates.<kind>` | `'{title} \| {siteName}'`; the pairs above | the title and description of each page kind |
| `seo.jsonLd.listing`, `.breadcrumbs`, `.itemList`, `.organization` | from `site.kind`; `true`; `true`; `true` | which JSON-LD each page emits |
| `seo.noindexBelow` | `2` | the listing count below which a category or tag page is `noindex` |
| `seo.robots.disallow` | `['/admin/', '/api/', '/out/', '/submit/status/', '/search/']` | extra `Disallow:` prefixes, also dropped from the sitemap |
| `seo.strictLinks` | `false` | whether an orphan fails the build |
| `features.relatedLinks` | `true` | the related block on listing pages |
| `listing.relatedCount` | `6` | how many related listings, 0 to 24 |

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

## Files

- src/
  - lib/
    - seo.ts titles, canonical, `noindex`, robots prefixes, sitemap chunks
    - jsonld.ts the JSON-LD builders
    - paths.ts every URL, always with a trailing slash
  - components/seo/Head.astro the head tags
  - pages/robots.txt.ts `/robots.txt`
  - features/paid/related.ts the related-links engine
- integrations/
  - sitemap.ts the chunked sitemap and its drop rules
  - seo-report.ts the report and the orphan failure
- lighthouserc.cjs `pnpm lhci`

## Related

- [Programmatic SEO](https://zerodirs.com/docs/configure/pseo/) — The generated pages and the pseo: build line.
- [OG images](https://zerodirs.com/docs/configure/og-images/) — One PNG per listing and category, rendered at build time.
- [Blog and feeds](https://zerodirs.com/docs/configure/blog-and-feeds/) — Posts, RSS and how they enter the sitemap.
- [Troubleshooting](https://zerodirs.com/docs/run/troubleshooting/) — The build stopped on an orphan; canonicals point at the wrong domain.
