Skip to content

Configuration

Markdown

One file carries roughly 80% of the customisation. site.config.ts is TypeScript, not JSON or YAML, so your editor completes it and the compiler catches a typo before the validator does.

import { defineSiteConfig } from './src/config/define';
export const siteConfig = defineSiteConfig({
site: { /* … */ },
routes: { /* … */ },
categories: [ /* … */ ],
tiers: [ /* … */ ],
});

defineSiteConfig() does three things in order: parses the object against a zod schema and fills in every default, runs ten cross-field rules that no single field can express, then merges categories[].icon into theme.icons and deep-freezes the result. Nothing downstream can mutate the shared config object, which is why a page template can hold a reference to it without defensive copying.

Every field, with its type, default and validation message, is in the configuration reference — which is generated from that schema at build time rather than written by hand. This page is the part a generated table cannot say.

site, routes, categories and tiers. Delete any other block entirely and its defaults apply; a fresh clone parses with every other block missing.

That is not a stylistic choice. It is what makes the config honest about scope: those four are the things a directory cannot exist without — who you are, what your URLs look like, how content is organised, and what a submission costs. Everything else has a defensible default.

$ pnpm check:config
site.config.ts is invalid (3 issues):
site.config.ts › site.description: must be at most 160 characters (home meta description)
site.config.ts › routes.listingBase: "blog" is a reserved path segment
site.config.ts › tiers[1].featuredDays: required when kind is "featured"

Three properties of that output are deliberate:

  • The whole file at once. You never fix one problem and rediscover the next on the next run.
  • A path, not a description. tiers[1].featuredDays is a thing you can jump to. This matters more than it sounds when an agent is doing the fixing — it can go straight to the field instead of re-reading the file looking for what you meant.
  • A prefix that names the file. The same validator runs from three places (the config check, the build, the tests), and the message reads identically from all three.

Field-level validation catches “this is not a valid slug”. These catch “this is a valid slug that contradicts something else in the file”, and they run only after the whole object has parsed and defaults have been applied. They live in crossRules() in src/config/schema.ts, one per numbered comment, each with its own unit test.

  1. The three routes.*Base values are pairwise distinct and none is a reserved segment. The reserved list is in the reference; blog and search are the two people hit.
  2. Slugs and ids are unique inside their own array, and none is page — that one is reserved for pagination, so a category slugged page would collide with /categories/page/2/.
  3. At least one tier is enabled; kind: 'featured' requires featuredDays and every other kind forbids it; an enabled tier with a price above zero requires features.payments and a payment provider. That last clause is why prices can stay in the file while checkout is off: disable the tier and its price is documentation rather than a promise.
  4. Each pSEO template id is registered and appears once, its placeholders are known, and maxItems ≥ minItems.
  5. Theme token values are raw CSS, not Tailwind class names, and cannot contain ;, } or < — they are emitted straight into a <style> element.
  6. theme.font.preloadWeight is one of theme.font.weights. Preloading a weight you never downloaded is a wasted request and a font that never arrives.
  7. Every placeholder in seo.templates is a known token, and providers.emailFrom is present and well-formed whenever the email provider is not console.
  8. site.url and media.baseUrl are https:// with no trailing slash.
  9. listing.customFields[].key is unique and does not shadow a built-in listing column, and a select field has options.
  10. The static-file budget — the estimate itself lives in src/lib/budget.ts and is printed by both pnpm check:config and the build. See Cost and limits.

Two defaults worth understanding before you change them

Section titled “Two defaults worth understanding before you change them”

features.* is off unless the code behind it works. A flag being false in the defaults does not mean the feature is missing; it means turning it on requires something you have not set up yet — a D1 database, a Stripe key. A fresh clone lands on the set that builds and runs with no account at all.

seo.strictLinks defaults to false and the demo sets it true. With it on, the build fails when a listing has fewer than two inbound internal links. That is a strict rule to hand somebody on day one, which is why it is off by default — but it is the single most valuable switch in the file once your content has settled, because an orphan page is a page Google will struggle to find and nothing else will tell you about it.

Secrets, and build-time variables. site.config.ts is committed, and a test greps it for secret-shaped values.

  • .env — build-time: LISTINGS_SOURCE, ZERODIRS_DATASET, and the D1_* group.
  • .dev.vars — runtime secrets for local development; in production these are wrangler secret put. None is needed by the static build.

The split is enforced by Cloudflare’s own rules, not by taste: a public astro:env field is inlined into the bundle as a constant at build time, so anything that must stay secret has to be declared as a server secret and read at request time. Environment and secrets has the table.