# Troubleshooting

> The build failures and first-hour surprises a ZeroDirs site produces, what each message actually means, and the one-line fix.

You are reading one page of the ZeroDirs documentation. ZeroDirs is an Astro + Cloudflare Workers starter for directory sites: every page is rendered to static HTML at build time, the site ships no client JavaScript outside `/search/`, and one file — `site.config.ts` — carries roughly 80% of the customisation.

Two things to hold on to before you act on anything below:

- `site.config.ts` is validated by a zod schema with ten cross-field rules. After any edit to it, run `pnpm check:config`; every problem is reported as `site.config.ts › <path>: <message>` and the whole file is checked at once.
- The repository ships its own `AGENTS.md` with eighteen hard rules, and a machine-checked test suite behind them. If you are working inside a ZeroDirs project, read that file first — it overrides anything general you infer from this page.

Source: https://zerodirs.com/docs/run/troubleshooting/

---

Almost every failure in this project names the thing that is wrong. This page is the index from
message to cause.

## Configuration

### `site.config.ts is invalid (N issues)`

Every issue reads `site.config.ts › <path>: <message>`. Fix the named field and re-run
`pnpm check:config` — the whole file is validated at once, so the list you get is the complete
list.

```
site.config.ts › routes.listingBase: "blog" is a reserved path segment
```

The reserved list is in the [configuration reference](https://zerodirs.com/docs/configure/reference/#registries).
`blog` and `search` are the two that catch people.

### `must be a CSS value, not a Tailwind class name`

`theme.tokens` takes raw CSS. `primary: 'bg-blue-600'` is a class name; `primary: 'oklch(0.55
0.2 260)'` is a value. See [Theming](https://zerodirs.com/docs/configure/theming/#overriding-tokens).

## The build stops

### On an unknown category

```
listings › my-tool: unknown category "marketing" (slug "my-tool"); categories in
site.config.ts: writing, chatbots, image-generation, coding, …
```

A listing's `category` must be one of your `categories[].slug` values. Fatal on purpose: the last
good deploy stays live instead of a page silently vanishing. `pnpm listings:import` reports the
same per row, before writing anything.

### On orphan pages

```
seo-report: 1 orphan listing(s) with fewer than 2 inbound links (seo.strictLinks=true):
/tools/my-tool/ (1 from /categories/writing/page/2/)
```

`seo.strictLinks: true` fails the build when a listing has fewer than two inbound internal links —
a page a search engine will struggle to find. The message names each page and where its links come
from. Usually the cause is a listing whose tags are unique to it, so no tag page and no generated
page links to it; give it a tag it shares with something else. Set `seo.strictLinks: false` to
demote it to a warning.

### On the file budget

The build fails above 90% of the plan limit and warns above 80%. Turn off `seo.ogScope.pseo`
first, then `seo.ogImages: 'static'`. See [Cost and limits](https://zerodirs.com/docs/run/cost-and-limits/).

### On an unexpected script

The zero-JavaScript rule is enforced. If you added a component that hydrates, or a `<script src>`
on a content page, the build stops and names the page. `/search/` is the one exemption; JSON-LD
`<script type="application/ld+json">` is not counted.

## Content

### Tags vanish instead of failing

Deliberate. Unknown tags are dropped with a warning so that one typo cannot block 299 good rows:

```
[WARN] [zerodirs-files] listings › my-tool: unknown tag(s) "no-code" filtered
(slug "my-tool"; not in site.config.ts tags)
```

Watch for it in combination with `seo.strictLinks` — the dropped tag may have been the listing's
only inbound link.

### A logo is rejected

```
Invalid string: must match pattern /^(https:\/\/|\/)/
```

`logo` must be an absolute `https://` URL or a `/`-rooted public path (`/logos/acme.svg`). A
relative `./acme.png` fails. The schema is shared with the database loader, which is why there is
no Astro `image()` helper here — see [Listings and datasets](https://zerodirs.com/docs/configure/data/).

### A custom field renders nothing

Values that are objects or arrays are skipped, with no warning. Scalars render.

## Development

### Search finds nothing under `pnpm dev`

Expected, and the surprise everybody hits in the first hour. Pagefind only indexes during a real
build, so in dev the panel says:

> Search is not available yet. Run `pnpm build`, then `pnpm preview`, to try search locally.

Run `pnpm build`, then `pnpm preview`. Nothing is wrong with the island.

### `listings: LISTINGS_SOURCE is unset …`

A harmless first-run notice naming the missing D1 variables. Copy `.env.example` to `.env` and
keep `LISTINGS_SOURCE=files` to silence it.

### A class I used renders unstyled

Tailwind's automatic content detection is off, and `global.css` carries an explicit `@source`
allow-list instead. A class used only in a directory that is not on that list is **never
generated**, the page renders unstyled, and nothing warns you. Add the directory.

## Deploy

### Stripe reports failed deliveries

The webhook URL needs its trailing slash: `https://example.com/api/stripe/webhook/`. Without one,
Cloudflare answers with a 308 before route matching, and Stripe records the redirect as a failed
delivery and retries for days. Same for the Deploy Hook URL. See
[Deploy](https://zerodirs.com/docs/deploy/git-push/#every-dynamic-url-ends-in-a-slash).

### Canonical tags point at the wrong domain

`site.url` was still the previous value when you built. It is baked in at build time — canonicals,
sitemap, feeds and OG image URLs all embed it. Fix it and build again.

### `pnpm check` type-errors on a binding

Run `pnpm cf:typegen` after any change to `wrangler.jsonc` or the binding set.

### The deploy looks different from what I built

Two deploy paths racing. Once Workers Builds is connected, stop running `pnpm deploy` by hand.

## At run time

Everything below comes from the running Worker: the configuration report at the top of
`/admin/`, an HTTP status, or a log line. Each heading is the fixed part of one message.

### `Missing secret ADMIN_SECRET`

The full line is `Missing secret ADMIN_SECRET — run: wrangler secret put ADMIN_SECRET (local: add to .dev.vars). Nobody can sign in to /admin/.`
The Worker was deployed without the secret, so `/admin/login/` answers 503 to every attempt.
`pnpm cf:secrets ADMIN_SECRET` prompts for a value of at least 32 characters; `pnpm cf:setup`
sets it together with `TOKEN_SECRET`. See [Admin](https://zerodirs.com/docs/submissions/admin/).

### `ADMIN_SECRET is N characters; at least 32 are required`

Same fix, a longer value. Logins are refused until it is replaced.

### `ADMIN_SECRET is still the value from .dev.vars.example`

The secret on the Worker is one of the placeholders that ship with every copy of the starter,
so anyone holding the starter can sign in. The report gives the fix:
`node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"`, then
`wrangler secret put ADMIN_SECRET` — or `pnpm cf:secrets ADMIN_SECRET` with the value it
printed. Update `.dev.vars` too, so local and production agree.

### `TOKEN_SECRET is still the value from .dev.vars.example`

The same placeholder problem for the signing key: every status link, checkout link and admin
cookie is forgeable. Same fix, for `TOKEN_SECRET`.

### `Missing secret TOKEN_SECRET`

Nothing can be signed: no status links, no checkout links, no admin cookie, and `/submit/`
refuses to create a submission. `pnpm cf:secrets TOKEN_SECRET`. See
[Environment and secrets](https://zerodirs.com/docs/deploy/environment/#runtime-secrets).

### `Missing secret STRIPE_SECRET_KEY`

Reported while `features.payments` is on with `providers.payment: 'stripe'`. Paid tiers show on
the form, but the checkout answers 503 until the key is on the Worker:
`pnpm cf:secrets STRIPE_SECRET_KEY STRIPE_WEBHOOK_SECRET`. See
[Plans and payments](https://zerodirs.com/docs/submissions/payments/).

### `Missing secret STRIPE_WEBHOOK_SECRET`

The other half of the pair, one line per key. One without the other degrades the provider to
`none`.

### `Missing secret RESEND_API_KEY`

`providers.email` is `'resend'` and the Worker has no key. The line ends "Queued mail stays in
email_outbox", but that is not what happens: without the key the site falls back to the console
provider, which logs each message and reports success, so the rows are marked `sent` and nothing
leaves. `pnpm cf:secrets RESEND_API_KEY`. See [Email](https://zerodirs.com/docs/submissions/email/).

### `providers.email is "console"`

A warning, not an error: the default provider logs mail instead of sending it, so submitters get
no receipt and no decision. Set `providers.email` to `'cloudflare'` or `'resend'` in
`site.config.ts`, with `providers.emailFrom`. See [Email](https://zerodirs.com/docs/submissions/email/).

### `Missing secret DEPLOY_HOOK_URL`

`providers.rebuild` is `'deploy-hook'` and the hook URL is not on the Worker, so approving a
listing records a build request and nothing builds; `/admin/` offers **Publish now** meanwhile.
Create the Deploy Hook in Workers Builds and `pnpm cf:secrets DEPLOY_HOOK_URL`. See
[Publishing on approval](https://zerodirs.com/docs/submissions/publishing/).

### `Missing secret GITHUB_TOKEN`

The `'github-dispatch'` trigger needs `GITHUB_TOKEN` and `GITHUB_REPO`; with either missing it
falls back to `none` and approvals do not publish. `pnpm cf:secrets GITHUB_TOKEN GITHUB_REPO`.

### `Missing secret GITHUB_REPO`

The other half of the same pair; see the entry above.

### `providers.rebuild is "none"`

A warning: approved listings stay off the site until someone publishes. Run `pnpm deploy` after
approving, or set `providers.rebuild` to `'deploy-hook'` and add `DEPLOY_HOOK_URL`. See
[Publishing on approval](https://zerodirs.com/docs/submissions/publishing/).

### Mail is marked sent but never arrives

`providers.email: 'cloudflare'` without the `EMAIL` binding in `wrangler.jsonc`, or `'resend'`
without its key. Both degrade to the console provider, which logs and reports success, so
`email_outbox` rows read `sent` while nothing was delivered. `/admin/` shows nothing for the
first case; only `pnpm cf:setup` mentions the binding, when it leaves it out for an account with
no sending domain. Check the rows:

```sh title="Terminal"
pnpm exec wrangler d1 execute DB --remote --command "SELECT template, status, attempts, last_error, sent_at FROM email_outbox ORDER BY created_at DESC LIMIT 5"
```

`sent` with a `sent_at` and no mail in anyone's inbox is the console provider. Restore the
binding (or the key), `pnpm cf:typegen`, deploy. See [Email](https://zerodirs.com/docs/submissions/email/).

### `cloudflare E_SENDER_NOT_VERIFIED`

In `last_error`: the binding is there, but the sending domain is not enabled for the zone, or
the account is not on Workers Paid. The row stays `pending`, the hourly job retries it, and
after five attempts it is parked as `failed`; the dashboard then offers **Resend** on the
listing. Enable the domain (`pnpm exec wrangler email sending enable <domain>`), or switch to
Resend.

### 503 `Payment is temporarily unavailable`

The checkout link opened without a usable provider — a Stripe key missing, or Stripe refused to
create the session — or without `TOKEN_SECRET`. The full body is `Payment is temporarily unavailable. Nothing has been charged — please try this link again in a few minutes.`
with `Retry-After: 60`. A bad or missing token, an unknown listing, a free tier or payments off
answer 404 instead. Put the secrets on the Worker and the same link works. See
[Plans and payments](https://zerodirs.com/docs/submissions/payments/).

### 404 on `/account/`, `/login/`, `/api/newsletter/` or `/out/`

The feature is off. `/account/`, `/login/` and `/auth/callback/` answer `Not found` unless
`features.accounts` is `true` (`/auth/google/` redirects to `/login/` instead);
`/api/newsletter/` unless `features.newsletter` is `'d1'`; `/out/<id>/` unless
`features.clicks` is `'ping'` or `'redirect'`. `/advertise/` is not built at all while
`ads.page.enabled` is `false`. `/submit/` is the exception: it is **not** gated by
`features.submissions`. The flag only decides whether the header and footer link to it; the
page renders and accepts submissions either way.

### Stripe shows failed deliveries

Covered under [Deploy](https://zerodirs.com/docs/run/troubleshooting/#stripe-reports-failed-deliveries): the endpoint is
missing its trailing slash. Once it is reached, the webhook answers for itself: 400
`{"error":"invalid signature"}` for the wrong `STRIPE_WEBHOOK_SECRET`, 503
`{"error":"payments are not configured"}` while payments are on without a provider, 404
`{"error":"not found"}` while `features.payments` is off, and 405 for anything but a POST.

### `redirect_uri_mismatch`

Google refuses the sign-in because the redirect URI you registered is not the one the site sent.
The site sends `site.url` + `/auth/google/callback/`, trailing slash included, built from
`site.config.ts` and never from the request. Register
`https://<your domain>/auth/google/callback/` for production. For a local test, `site.url` has
to be the origin the browser is on, and the schema accepts only an `https://` value, so
registering `http://localhost:4321/…` on its own is not enough. See
[Submitter accounts](https://zerodirs.com/docs/submissions/accounts/).

### `That is not the admin secret` / `This deployment has no ADMIN_SECRET`

Two different answers from `/admin/login/`. The first is a 401: the password did not match
`ADMIN_SECRET`; check `.dev.vars` locally, or the value you put with `pnpm cf:secrets`. The
second is a 503, in full `This deployment has no ADMIN_SECRET, so nobody can sign in. Set one and redeploy.`:
the Worker has no secret at all, and no password works until one is set. Any other `/admin/`
page redirects an anonymous request to `/admin/login/?next=…` rather than refusing it. See
[Security](https://zerodirs.com/docs/run/security/#admin-authentication).

### The hourly jobs did not run locally

Under `pnpm dev` the scheduled URL is the site's 404 page: Astro's dev server does not run the
Worker's `scheduled()` handler. What works is the built Worker under wrangler:

```sh title="Terminal"
pnpm build && pnpm exec wrangler dev
curl "http://localhost:8787/cdn-cgi/local/scheduled?cron=0+*+*+*+*"
```

wrangler's log then shows one `jobs: expireFeatured=… flushOutbox=… …` line. See
[Admin](https://zerodirs.com/docs/submissions/admin/#the-hourly-jobs).

### A submission vanished after the honeypot

On purpose. A filled honeypot field answers with the same 303 to `/submit/?sent=1` and the same
"Thanks — we have your submission." banner as a real one; nothing is written and nothing is
sent, so a bot cannot tell it was caught. If a person hit it, an autofill extension filled the
hidden field. The other silent-looking case is the per-address limit, a rendered form message
rather than a status: `That address has submitted N times in the last 24 hours, which is the limit. Please try again tomorrow.`
The number is `limits.submissionsPerEmailPerDay`. See [The submission form](https://zerodirs.com/docs/submissions/form/).

### `[zerodirs-files]` in the build log when you meant D1

The build used files mode. Two lines to look for. The first, printed before the loader runs,
means `LISTINGS_SOURCE` is unset and a credential is missing:

```text
listings: LISTINGS_SOURCE is unset and CLOUDFLARE_ACCOUNT_ID, D1_DATABASE_ID, D1_READ_TOKEN are missing — using files (src/content/listings). Set LISTINGS_SOURCE=files to silence this warning.
```

A warning only: the build succeeds from Markdown, and
`[zerodirs-files] listings: N from src/content/listings (…)` follows. To build from the database,
set `LISTINGS_SOURCE=d1` with all three variables. With `LISTINGS_SOURCE=d1` and one of them
missing, the build stops instead:

```text
listings loader: LISTINGS_SOURCE=d1 but D1_READ_TOKEN is not set. The D1 REST loader needs CLOUDFLARE_ACCOUNT_ID, D1_DATABASE_ID, D1_READ_TOKEN at build time — put them in .env locally, or in Workers Builds → Settings → Build variables (D1_READ_TOKEN as a secret). D1_READ_TOKEN is an account API token with the "D1 Read" permission scoped to this one database; it is not the Workers Builds deploy token and it is not CLOUDFLARE_API_TOKEN. Set LISTINGS_SOURCE=files to build from src/content/listings instead.
```

See [Listings from D1](https://zerodirs.com/docs/deploy/listings-from-d1/).

## Still stuck

Every command in this project prints more than the error line. Quote the whole output and the
version from `CHANGELOG.md` — the lines above the failure usually contain the diagnosis.
