Skip to content

Make it yours

Markdown

Everything here is an edit to site.config.ts or one command, in the order that avoids redoing work: the noun and the URL scheme before the content, site.url before the build. Each step ends with a check; the Quickstart and pnpm dev come first.

  1. Name it.

    site.config.ts
    site: {
    name: 'Acme Directory', // header, footer, page titles, JSON-LD
    tagline: 'The best widgets, ranked', // the home page heading and its title
    description: 'A curated directory of widgets, compared on price and features.', // home meta description
    url: 'https://acme.com', // https, no trailing slash
    contactEmail: 'hello@acme.com', // footer and about page
    locale: 'en', // html lang, feed language, date formatting
    kind: 'software', // software | service | business
    },

    description is capped at 160 characters: it is the home page’s meta description. kind sets the default of seo.jsonLd.listing: software emits SoftwareApplication, service Organization, business LocalBusiness — which needs address and telephone custom fields on the listing, else Organization.

    pnpm check:config answers site.config.ts OK — Acme Directory (https://acme.com); ….

  2. Give it its URLs and its noun.

    site.config.ts
    routes: {
    listingBase: 'widgets', // /widgets/<slug>/ and /widgets/page/2/
    categoryBase: 'categories', // /categories/<slug>/
    tagBase: 'tags', // /tags/<slug>/
    listingNoun: { singular: 'widget', plural: 'widgets' },
    },

    The three bases must differ, and none may be a reserved segment such as blog, search or admin: site.config.ts › routes.listingBase: "blog" is a reserved path segment. listingNoun fills {noun} and {nouns} in every SEO template, the footer’s Submit a widget link and the about page. Decide the bases now: changing one later changes every canonical, sitemap entry and OG image URL, and the old pages stop existing.

    Open http://localhost:4321/widgets/: the listing index, paginated at listing.perPage.

  3. Categories and tags.

    site.config.ts
    categories: [
    { slug: 'blue', name: 'Blue', description: 'Widgets that are blue.', icon: 'palette', order: 1 },
    { slug: 'fast', name: 'Fast', description: 'Widgets that are quick.', icon: 'zap', order: 2 },
    ],
    tags: [
    { slug: 'open-source', name: 'Open source', description: 'Source available.' },
    ],

    A category is a page, a breadcrumb and a sitemap entry; a tag is a label. order sorts the chips, the grid and the neighbour links; seoTitle and seoDescription replace the templates for that page. icon is a lucide icon name (browse lucide.dev); defineSiteConfig() merges category icons into theme.icons, the list astro-icon bundles, so never add them there — that list is for template icons; src/icons/ holds your own SVGs.

    Every listing’s category must be one of these slugs, or the build stops and the previous deploy stays live:

    listings › my-widget: unknown category "green" (slug "my-widget"); categories in site.config.ts: blue, fast

    An unknown tag is only dropped: listings › my-widget: unknown tag(s) "beta" filtered (slug "my-widget"; not in site.config.ts tags). An unused tag gets no page; a category always does, noindex below seo.noindexBelow listings.

    pnpm check:config counts <n> categories, <n> tags; on /widgets/ the chips are now yours:

    Listing index: category chips and listing cards

  4. Tiers.

    site.config.ts
    tiers: [
    { id: 'free', name: 'Free', description: 'Reviewed in order, listed for free.', kind: 'queue', priceCents: 0, currency: 'usd', enabled: true },
    ],

    Required in files mode too (at least one tier is required, one enabled): every listing’s tier reads it, and the free tier alone is complete. A priced tier (kind: 'express', or 'featured' with featuredDays) can only be enabled with features.payments: true and providers.payment: 'stripe'; the demo ships both priced tiers on, so turning payments off without disabling them fails: tiers[1].priceCents: must be 0 when features.payments is false. Keep the prices; the pricing table renders from them. Selling is Plans and payments.

    pnpm check:config prints <n> tiers (<n> enabled).

  5. Validate.

    Terminal
    pnpm check:config

    A failure names every field path at once:

    site.config.ts is invalid (2 issues):
    site.config.ts › site.description: must be at most 160 characters (home meta description)
    site.config.ts › routes.tagBase: must differ from routes.categoryBase ("categories")

    Success prints the counts and the file estimate against budget.plan:

    site.config.ts OK — Acme Directory (https://acme.com); <n> categories, <n> tags, <n> tiers (<n> enabled), <n> pSEO templates, preset "default", <n> icons
    listings: <n> (src/content/listings); logos served directly
    budget: <n> listings × <n> files + 3000 overhead ≈ <n> / 20000 files (free plan, <n>%) — ok
    headroom: up to ~<n> listings before the 90% limit on the free plan

    Above 80% of the plan it warns, above 90% it exits 1, as the build would — Cost and limits. Every field is in the reference.

  6. Replace the demo content.

    Terminal
    pnpm content:reset --yes

    Without --yes it only prints what it would delete. With it, it empties src/content/listings/, src/content/blog/ and public/logos/, keeps unpublished-draft.md (the draft: true fixture), and writes one example-<category>.md per category from your own config — first two tags, first pricing type, first tier, no logo — capped at 6 (--count N), plus a hello-world.md post:

    reset: removed <n> listings, <n> blog posts, <n> logos
    reset: wrote <n> example listings (one per category, capped at 6 of <n> categories) and src/content/blog/hello-world.md
    reset: kept src/content/pseo/example-override.md
    reset: kept src/content/blog/unpublished-draft.md (starter fixture, draft: true — delete it if you do not want it)
    next: pnpm check:config && pnpm dev — or tsx scripts/content.ts use ai-tools --yes to put the demo data back

    Then bring your listings in:

    Terminal
    pnpm listings:import widgets.csv --dry-run # every bad row in one pass; writes nothing
    pnpm listings:import widgets.csv

    CSV or JSON, columns on Listings and datasets; an unknown category is a hard error here too. For a different sample, pnpm content:use indie-tools --yes copies that dataset in and prints the categories and tags blocks to paste, never editing site.config.ts.

    pnpm check:config now says listings: <n> (src/content/listings), and every category page shows its example.

  7. Brand it.

    Four pieces of ZeroDirs branding live outside the config:

    What Where What to do
    Favicon and header logo public/favicon.svg Overwrite it: site.logo: '/favicon.svg' makes it the header logo and the JSON-LD Organization logo. Remove site.logo for a text-only header
    Social-share image public/og-default.png pnpm og:default regenerates it from site.name, site.tagline and site.description. Every page without a generated image uses it; under seo.ogImages: 'static', every page
    About page src/pages/about.astro The paragraphs under “How widgets get in” and “Featured placement is disclosed” are the demo’s editorial policy — rewrite them
    Handles seo.twitterHandle, site.social the twitter:site meta tag; the footer’s X and GitHub links and sameAs in JSON-LD. All still say zerodirs

    The footer is all config: name, tagline, contact email, the nav, the links each feature switches on, Terms and Privacy once legal.termsUrl and legal.privacyUrl are set (both pages ship), and Built with ZeroDirs, off with site.builtWith: false; the licence does not require it.

    The demo footer: link columns, newsletter form, Terms, Privacy and Built with ZeroDirs

    pnpm og:default --check answers public/og-default.png is up to date (<n> bytes, "Acme Directory") and exits 1 when stale — put it in CI.

  8. Switch things on or off.

    The defaults need no account; the demo file turns on what its account provides. A flag without the thing it needs buys a route that answers 503 — open its page first.

    features. Default Demo What it adds Page
    submissions false true the Submit link; the form and review queue need D1 Submissions
    accounts false true sign-in by emailed link or Google, and /account/ Submitter accounts
    payments false true Stripe Checkout for priced tiers; needs two secrets Plans and payments
    newsletter 'off' 'd1' the footer form: 'embed' renders newsletter.embedHtml, 'd1' stores subscribers in D1 Newsletter
    blog true true /blog/, /blog/rss.xml, the Blog nav item Blog and feeds
    search true true /search/ and the search boxes; the index exists only after pnpm build Search
    clicks 'off' 'ping' click counting through /out/<id>/: 'ping' adds a beacon, 'redirect' routes the click Click tracking
    relatedLinks true true the related list on detail pages Programmatic SEO
    pseo true true the generated pages from pseo.templates Programmatic SEO
    honeypot true true a hidden field on both forms; a filled one is dropped silently Submissions

    Ads are not a flag: each placement is a boolean in the ads block — Ads and sponsors. The demo ships its top banner as a house ad for ZeroDirs; give ads.topBanner your own text and url, or set it to enabled: false.

    pnpm check:config fails when a flag contradicts another block, and /admin/ lists what a switched-on flag still needs, such as Missing secret STRIPE_SECRET_KEY — run: wrangler secret put STRIPE_SECRET_KEY (local: add to .dev.vars). Paid tiers cannot complete a checkout.

  9. Pick a theme.

    theme: { preset: 'warm' } — the presets are default, warm and mono; darkMode is system, light or dark; theme.tokens overrides colours, radii and fonts — Theming. pnpm check:config prints preset "warm".

  10. Set site.url to the real domain, and build.

    Terminal
    pnpm check:config && pnpm build

    site.url is baked in at build time: canonicals, the sitemap, both feeds and the absolute og:image URLs embed it. Built with the demo value, every canonical points at someone else’s domain, and search engines follow it; pnpm cf:setup refuses to run while it still says demo.zerodirs.com or example.com. Read these lines:

    routes: <n> directory pages (<n> listings, <n> index pages, <n> category pages, <n> tag pages, <n> pseo, <n> noindex) → node_modules/.cache/zerodirs/routes.json (<n> routes incl. static pages)
    pseo: generated <n> (dropped by minItems <n>, truncated <n>)
    sitemap: <n> urls in <n> chunks (listings=<n>, taxonomy=<n>, pseo=<n>, blog=<n>, pages=<n>); dropped <n> (search/noindex/disallow/file); pseo slugs: <n>
    budget: <n> files / 20000 (<n>%) — free plan, ok
    zero-js: <n> content pages, 0 islands, 0 external scripts — ok
    seo-report: scanned <n> pages; <n> listings; <n> noindex; pseo <n> pages (best-category-for-tag=<n>, name-alternatives=<n>)
    seo-report: inbound links per listing: min <n>; orphans (fewer than 2 inbound): 0 [strictLinks=on]

    budget and zero-js must end in ok. With seo.strictLinks: true, the demo’s setting, an orphan listing fails the build; the default false only warns. SEO explains each line.

pnpm preview serves the build. Open these on the URL it prints:

Open You should see
The home page / your name in the header, your tagline as the heading, your categories as chips
A category /categories/<slug>/ its listings — after the reset, the one example — and its icon beside the heading
A listing /widgets/<slug>/ the detail page, an initial-letter tile until it has a logo
Robots /robots.txt User-agent: *, Disallow: lines, Sitemap: https://acme.com/sitemap-index.xml with your domain
The sitemap /sitemap-index.xml sitemap-listings-0.xml, sitemap-taxonomy-0.xml, sitemap-blog-0.xml, sitemap-pages-0.xml — and sitemap-pseo-0.xml once a generated page exists — each starting with your site.url