Skip to content

SEO and the report

Markdown

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.

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.

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.

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.

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.

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 (softwaresoftware; serviceorganization; businesslocalBusiness). 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.

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 report scans every index.html under dist/client and prints four lines:

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:

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. 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. zero-js: N content pages, 0 islands, 0 external scripts — ok fails when a content page other than /search/ ships a script.

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

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
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, features and listing.

  • Directorysrc/
    • Directorylib/
      • 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
  • Directoryintegrations/
    • sitemap.ts the chunked sitemap and its drop rules
    • seo-report.ts the report and the orphan failure
  • lighthouserc.cjs pnpm lhci