Configuration reference
This page is generated from src/config/schema.ts at build time — the same module pnpm check:config runs. Nothing here is typed by hand, so it cannot fall behind the code: rename a field and this page renames it on the next build. 201 fields across 20 blocks.
Read it alongside Configuration, which covers the four required blocks, the error format and the ten cross-field rules that no field table can express.
A field with no default is required. x[] in a path is one array item, so categories[].slug is the slug of each entry in categories.
Identity. url is baked into canonicals, the sitemap, the feeds and the absolute OG image URLs at build time, so it has to be the real domain before the final build.
| Field | Type | Default | Rules |
|---|---|---|---|
site |
object |
required | |
name |
string |
required | |
tagline |
string |
required | |
description |
string |
required | must be at most 160 characters (home meta description) |
url |
url |
required | must be an https:// URL; must not end with “/” |
kind |
'software' | 'service' | 'business' |
required | |
locale |
string |
'en' |
at least 2 characters |
logo |
string |
— | must start with / |
social |
object |
— | |
social.twitter |
string |
— | |
social.github |
string |
— | |
contactEmail |
email |
required | |
builtWith |
boolean |
true |
routes
Section titled “routes”The URL scheme. Rename a base and every link in the site moves with it, because src/lib/paths.ts is the only place a URL is constructed.
| Field | Type | Default | Rules |
|---|---|---|---|
routes |
object |
required | |
listingBase |
string |
required | must match ^[a-z0-9-]+$ |
categoryBase |
string |
required | must match ^[a-z0-9-]+$ |
tagBase |
string |
required | must match ^[a-z0-9-]+$ |
listingNoun |
object |
required | |
listingNoun.singular |
string |
required | |
listingNoun.plural |
string |
required |
categories
Section titled “categories”Structural taxonomy. Every configured category always gets a page, even an empty one. A listing naming a category that is not here fails the build.
| Field | Type | Default | Rules |
|---|---|---|---|
categories |
object[] |
required | at least one category is required |
[].slug |
string |
required | must be a lowercase slug (a-z, 0-9, single dashes) |
[].name |
string |
required | |
[].description |
string |
— | |
[].seoTitle |
string |
— | |
[].seoDescription |
string |
— | |
[].icon |
string |
— | must be a lucide icon name (a-z, 0-9, -) |
[].order |
integer |
— |
Descriptive taxonomy, and the input to programmatic SEO. A listing naming a tag that is not here keeps building — the tag is dropped with a warning.
| Field | Type | Default | Rules |
|---|---|---|---|
tags |
object[] |
[] |
|
[].slug |
string |
required | must be a lowercase slug (a-z, 0-9, single dashes) |
[].name |
string |
required | |
[].description |
string |
— |
pricingTypes
Section titled “pricingTypes”The pricing badge vocabulary, and one of the search filters.
| Field | Type | Default | Rules |
|---|---|---|---|
pricingTypes |
object[] |
see rows below |
|
[].slug |
string |
required | must be a lowercase slug (a-z, 0-9, single dashes) |
[].name |
string |
required |
Submission plans. A tier with kind: "featured" is what makes a listing’s featuredUntil produce the badge — that lookup filters on kind, not on enabled.
| Field | Type | Default | Rules |
|---|---|---|---|
tiers |
object[] |
required | at least one tier is required |
[].id |
string |
required | must be a lowercase slug (a-z, 0-9, single dashes) |
[].name |
string |
required | |
[].description |
string |
required | |
[].kind |
'queue' | 'express' | 'featured' |
required | |
[].priceCents |
integer |
required | ≥ 0 |
[].currency |
string |
required | must be a lowercase ISO 4217 code, e.g. “usd” |
[].featuredDays |
integer |
— | > 0 |
[].slaHours |
integer |
— | > 0 |
[].features |
string[] |
[] |
|
[].enabled |
boolean |
true |
listing
Section titled “listing”How listings are sorted, paginated and rendered, plus per-listing custom fields.
| Field | Type | Default | Rules |
|---|---|---|---|
listing |
object |
see rows below |
|
perPage |
integer |
24 |
≥ 12; ≤ 60 |
relatedCount |
integer |
6 |
≥ 0; ≤ 24 |
defaultSort |
'featured' | 'popular' | 'newest' |
'featured' |
|
maxTags |
integer |
6 |
≥ 1; ≤ 20 |
screenshotMax |
integer |
3 |
≥ 0; ≤ 5 |
outboundRel |
'dofollow' | 'nofollow' |
'nofollow' |
|
customFields |
object[] |
[] |
|
customFields[].key |
string |
required | must match ^[a-z][a-zA-Z0-9]*$ |
customFields[].label |
string |
required | |
customFields[].type |
'text' | 'url' | 'select' | 'textarea' |
required | |
customFields[].options |
string[] |
— | |
customFields[].required |
boolean |
— | |
customFields[].showOnCard |
boolean |
— |
Meta templates, JSON-LD, OG images, noindex thresholds and the orphan check.
| Field | Type | Default | Rules |
|---|---|---|---|
seo |
object |
{} |
|
templates |
object |
see rows below |
|
templates.titleTemplate |
string |
'{title} | {siteName}' |
|
templates.home |
object |
see rows below |
|
templates.home.title |
string |
'{tagline}' |
|
templates.home.description |
string |
'Discover the best {nouns} on {siteName}. Browse {count} hand-picked {nouns} by category and tag.' |
|
templates.listing |
object |
see rows below |
|
templates.listing.title |
string |
'{name} - {tagline}' |
|
templates.listing.description |
string |
'{name}: {tagline}. Pricing, alternatives and reviews on {siteName}.' |
|
templates.category |
object |
see rows below |
|
templates.category.title |
string |
'Best {category} {nouns} ({count})' |
|
templates.category.description |
string |
'Compare {count} {category} {nouns}, hand-picked and updated for {year}.' |
|
templates.tag |
object |
see rows below |
|
templates.tag.title |
string |
'Best {tag} {nouns} ({count})' |
|
templates.tag.description |
string |
'Browse {count} {nouns} tagged {tag}, updated for {year}.' |
|
templates.pseo |
object |
see rows below |
|
templates.pseo.title |
string |
'{title}' |
|
templates.pseo.description |
string |
'{title} — {count} {nouns} compared, updated for {year}.' |
|
templates.blog |
object |
see rows below |
|
templates.blog.title |
string |
'{title}' |
|
templates.blog.description |
string |
'{title} — from the {siteName} blog.' |
|
jsonLd |
object |
{"breadcrumbs":true,"itemList":true,"organization":true} |
|
jsonLd.listing |
'software' | 'product' | 'organization' | 'localBusiness' | 'none' |
— | |
jsonLd.breadcrumbs |
boolean |
true |
|
jsonLd.itemList |
boolean |
true |
|
jsonLd.organization |
boolean |
true |
|
ogImages |
'generated' | 'static' |
'generated' |
|
ogScope |
object |
{"listings":true,"categories":true,"pseo":false} |
|
ogScope.listings |
boolean |
true |
|
ogScope.categories |
boolean |
true |
|
ogScope.pseo |
boolean |
false |
|
noindexBelow |
integer |
2 |
≥ 0 |
strictLinks |
boolean |
false |
|
robots |
object |
see rows below |
|
robots.disallow |
string[] |
["/admin/","/api/","/out/","/submit/status/","/search/"] |
each item must start with / |
twitterHandle |
string |
— |
Programmatic SEO: which patterns generate pages, the thin-content guard, and the page cap.
| Field | Type | Default | Rules |
|---|---|---|---|
pseo |
object |
{"enabled":true,"maxPages":2000,"templates":[]} |
|
enabled |
boolean |
true |
|
maxPages |
integer |
2000 |
≥ 0 |
templates |
object[] |
[] |
|
templates[].id |
'best-category-for-tag' | 'name-alternatives' | 'free-category' |
required | |
templates[].enabled |
boolean |
true |
|
templates[].minItems |
integer |
required | ≥ 1 |
templates[].maxItems |
integer |
— | ≥ 1 |
templates[].title |
string |
required | |
templates[].description |
string |
required | |
templates[].h1 |
string |
required | |
templates[].intro |
string |
required | |
templates[].faq |
object[] |
— | |
templates[].faq[].q |
string |
required | |
templates[].faq[].a |
string |
required |
Preset, token overrides, dark mode, fonts and the icon allow-list.
| Field | Type | Default | Rules |
|---|---|---|---|
theme |
object |
see rows below |
|
preset |
'default' | 'warm' | 'mono' |
'default' |
|
tokens |
Record<key, string> |
— | keys: primary, primaryFg, bg, fg, muted, mutedFg, surface, line, lineStrong, accent, featured, radius, radiusSm, font, fontDisplay; must be a CSS value, not a Tailwind class name; must not contain “;”, “}” or “<” |
darkTokens |
Record<key, string> |
— | keys: primary, primaryFg, bg, fg, muted, mutedFg, surface, line, lineStrong, accent, featured, radius, radiusSm, font, fontDisplay; must be a CSS value, not a Tailwind class name; must not contain “;”, “}” or “<” |
darkMode |
'system' | 'light' | 'dark' |
'system' |
|
font |
{ provider: 'system' } | { provider: 'fontsource' | 'google' | 'bunny', family, weights, preloadWeight } |
{"provider":"system"} |
|
font.provider |
'system' | 'fontsource' | 'google' | 'bunny' |
required | |
font.family |
string |
required | only when provider is ‘fontsource’ | ‘google’ | ‘bunny’ |
font.weights |
integer[] |
required | only when provider is ‘fontsource’ | ‘google’ | ‘bunny’; each item ≥ 100; each item ≤ 900 |
font.preloadWeight |
integer |
required | only when provider is ‘fontsource’ | ‘google’ | ‘bunny’; ≥ 100; ≤ 900 |
icons |
string[] |
[] |
each item must be a lucide icon name (a-z, 0-9, -) |
viewTransitions |
boolean |
false |
features
Section titled “features”The feature switches. A flag is only on by default when there is working code behind it.
| Field | Type | Default | Rules |
|---|---|---|---|
features |
object |
see rows below |
|
submissions |
boolean |
false |
|
accounts |
boolean |
false |
|
payments |
boolean |
false |
|
newsletter |
'off' | 'embed' | 'd1' |
'off' |
|
blog |
boolean |
true |
|
search |
boolean |
true |
|
clicks |
'ping' | 'redirect' | 'off' |
'off' |
|
relatedLinks |
boolean |
true |
|
pseo |
boolean |
true |
|
honeypot |
boolean |
true |
providers
Section titled “providers”Which external service each capability talks to. Every one of these degrades to an inert default rather than failing the build when its secret is missing.
| Field | Type | Default | Rules |
|---|---|---|---|
providers |
object |
see rows below |
|
email |
'resend' | 'cloudflare' | 'console' |
'console' |
|
emailFrom |
string |
'' |
|
adminNotify |
'same' | 'cloudflare-verified' | 'off' |
'same' |
|
payment |
'stripe' | 'none' |
'none' |
|
rebuild |
'deploy-hook' | 'github-dispatch' | 'none' |
'none' |
|
rebuildAuto |
boolean |
true |
The R2 custom domain images are served from. Inert in files mode, where no logo is served from it.
| Field | Type | Default | Rules |
|---|---|---|---|
media |
object |
{"baseUrl":"https://media.example.com"} |
|
baseUrl |
url |
'https://media.example.com' |
must be an https:// URL; must not end with “/” |
The review queue: who gets notified, and how long a session lasts.
| Field | Type | Default | Rules |
|---|---|---|---|
admin |
object |
{} |
|
email |
email |
— | |
sessionHours |
integer |
12 |
≥ 1; ≤ 720 |
The free review queue: what a submitter is told to expect before the site has approval history, and when the operator’s daily reminder goes out.
| Field | Type | Default | Rules |
|---|---|---|---|
queue |
object |
{} |
|
estimateDays |
integer |
21 |
≥ 1; ≤ 365 |
digestHourUtc |
integer |
9 |
≥ 0; ≤ 23 |
limits
Section titled “limits”Submission-side quotas and size caps.
| Field | Type | Default | Rules |
|---|---|---|---|
limits |
object |
see rows below |
|
submissionsPerEmailPerDay |
integer |
3 |
≥ 1 |
logoMaxBytes |
integer |
524288 |
> 0 |
screenshotMaxBytes |
integer |
2097152 |
> 0 |
descriptionMaxChars |
integer |
2000 |
≥ 80 |
unpaidTtlDays |
integer |
7 |
≥ 1 |
newsletter
Section titled “newsletter”The footer capture block.
| Field | Type | Default | Rules |
|---|---|---|---|
newsletter |
object |
see rows below |
|
embedHtml |
string |
— | |
heading |
string |
'Stay in the loop' |
|
blurb |
string |
'New {nouns} and updates, once a week. No spam.' |
Four ad placements — a banner under the header, a sponsor wall on the home page, a card inside listing grids, a corner card — each one boolean, plus the /advertise/ page that prices them.
| Field | Type | Default | Rules |
|---|---|---|---|
ads |
object |
{} |
|
contactEmail |
email |
— | |
page |
object |
{} |
|
page.enabled |
boolean |
false |
|
page.intro |
string |
'Put your product in front of the people who come here to compare. Four placements, sold directly, no ad network in between.' |
|
page.note |
string |
— | |
topBanner |
object |
{} |
|
topBanner.enabled |
boolean |
false |
|
topBanner.label |
string |
'Ad' |
|
topBanner.text |
string |
'Sponsor this directory — your product in front of every reader.' |
|
topBanner.url |
string |
'/advertise/' |
must be an https:// URL or a /-rooted path |
topBanner.linkLabel |
string |
'Learn more' |
|
sponsors |
object |
{} |
|
sponsors.enabled |
boolean |
false |
|
sponsors.heading |
string |
'Supported by these partners' |
|
sponsors.items |
object[] |
[] |
|
sponsors.items[].name |
string |
required | |
sponsors.items[].url |
string |
required | must be an https:// URL or a /-rooted path |
sponsors.items[].logo |
string |
— | must be an https:// URL or a /-rooted path |
sponsors.items[].tagline |
string |
— | |
inlineCard |
object |
{} |
|
inlineCard.enabled |
boolean |
false |
|
inlineCard.every |
integer |
6 |
≥ 2; ≤ 50 |
inlineCard.name |
string |
'Your product here' |
|
inlineCard.tagline |
string |
'A card inside every listing grid.' |
|
inlineCard.url |
string |
'/advertise/' |
must be an https:// URL or a /-rooted path |
inlineCard.logo |
string |
— | must be an https:// URL or a /-rooted path |
corner |
object |
{} |
|
corner.enabled |
boolean |
false |
|
corner.name |
string |
'Your product here' |
|
corner.tagline |
string |
'A small card on every page, dismissable.' |
|
corner.url |
string |
'/advertise/' |
must be an https:// URL or a /-rooted path |
corner.logo |
string |
— | must be an https:// URL or a /-rooted path |
rates |
object[] |
see rows below |
|
rates[].slot |
'topBanner' | 'sponsors' | 'inlineCard' | 'corner' |
required | |
rates[].price |
string |
required | |
rates[].blurb |
string |
required |
Footer links. Both pages are built and in the sitemap regardless; these fields only control the links.
| Field | Type | Default | Rules |
|---|---|---|---|
legal |
object |
{} |
|
termsUrl |
string |
— | |
privacyUrl |
string |
— |
budget
Section titled “budget”Which Cloudflare plan ceiling the build checks the static-file count against. It does not change your billing.
| Field | Type | Default | Rules |
|---|---|---|---|
budget |
object |
{"plan":"free"} |
|
plan |
'free' | 'paid' |
'free' |
Registries
Section titled “Registries”Exported by src/config/schema.ts and enforced by it. Generated from the same module as the tables above.
Reserved path segments (26)
Section titled “Reserved path segments (26)”No routes.*Base may be one of these, and the three bases must differ from each other.
sitemap · page · search · submit · admin · api · out · og · blog · advertise · login · account · auth · media · logos · pagefind · rss.xml · robots.txt · sitemap-index.xml · 404 · _astro · _actions · _headers · _redirects · _worker.js · cdn-cgi
SEO template placeholders (10)
Section titled “SEO template placeholders (10)”Allowed in every seo.templates string: {title} · {siteName} · {name} · {tagline} · {category} · {tag} · {count} · {noun} · {nouns} · {year}. Anything else is a config error, named by field path.
pSEO template placeholders (12)
Section titled “pSEO template placeholders (12)”The SEO set plus {pricing} · {topNames}, allowed in pseo.templates[].{title,description,h1,intro}.
pSEO patterns (3)
Section titled “pSEO patterns (3)”pseo.templates[].id must be one of best-category-for-tag · name-alternatives · free-category, and each may appear once.
Theme presets (3)
Section titled “Theme presets (3)”| Preset | Description |
|---|---|
default |
Blue brand, 0.75rem radius. CSS: oklch(0.55 0.2 260). |
warm |
Orange brand, 1rem radius. CSS: oklch(0.555 0.17 40). |
mono |
Black brand, no radius. |
Theme tokens (15)
Section titled “Theme tokens (15)”Keys of theme.tokens and theme.darkTokens: primary · primaryFg · bg · fg · muted · mutedFg · surface · line · lineStrong · accent · featured · radius · radiusSm · font · fontDisplay.
Built-in listing columns (22)
Section titled “Built-in listing columns (22)”A listing.customFields[].key may not shadow one of these: id · slug · name · url · tagline · description · category · tags · pricingType · tier · logo · screenshots · status · email · popularity · extra · approvedAt · featuredUntil · createdAt · updatedAt · submittedAt · paidAt.