Skip to content

Configuration reference

Markdown

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

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

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

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

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

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

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

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

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

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'

Exported by src/config/schema.ts and enforced by it. Generated from the same module as the tables above.

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

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.

The SEO set plus {pricing} · {topNames}, allowed in pseo.templates[].{title,description,h1,intro}.

pseo.templates[].id must be one of best-category-for-tag · name-alternatives · free-category, and each may appear once.

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.

Keys of theme.tokens and theme.darkTokens: primary · primaryFg · bg · fg · muted · mutedFg · surface · line · lineStrong · accent · featured · radius · radiusSm · font · fontDisplay.

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.