Configuration
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.
Four required blocks
Section titled “Four required blocks”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.
Reading an error
Section titled “Reading an error”$ pnpm check:configsite.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 segmentsite.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].featuredDaysis 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.
The ten cross-field rules
Section titled “The ten cross-field rules”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.
- The three
routes.*Basevalues are pairwise distinct and none is a reserved segment. The reserved list is in the reference;blogandsearchare the two people hit. - Slugs and ids are unique inside their own array, and none is
page— that one is reserved for pagination, so a category sluggedpagewould collide with/categories/page/2/. - At least one tier is enabled;
kind: 'featured'requiresfeaturedDaysand every other kind forbids it; an enabled tier with a price above zero requiresfeatures.paymentsand 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. - Each pSEO template id is registered and appears once, its placeholders are known, and
maxItems ≥ minItems. - Theme token values are raw CSS, not Tailwind class names, and cannot contain
;,}or<— they are emitted straight into a<style>element. theme.font.preloadWeightis one oftheme.font.weights. Preloading a weight you never downloaded is a wasted request and a font that never arrives.- Every placeholder in
seo.templatesis a known token, andproviders.emailFromis present and well-formed whenever the email provider is notconsole. site.urlandmedia.baseUrlarehttps://with no trailing slash.listing.customFields[].keyis unique and does not shadow a built-in listing column, and aselectfield hasoptions.- The static-file budget — the estimate itself lives in
src/lib/budget.tsand is printed by bothpnpm check:configand 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.
What does not belong in this file
Section titled “What does not belong in this file”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 theD1_*group..dev.vars— runtime secrets for local development; in production these arewrangler 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.