Troubleshooting
Almost every failure in this project names the thing that is wrong. This page is the index from message to cause.
Configuration
Section titled “Configuration”site.config.ts is invalid (N issues)
Section titled “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 segmentThe reserved list is in the configuration reference.
blog and search are the two that catch people.
must be a CSS value, not a Tailwind class name
Section titled “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.
The build stops
Section titled “The build stops”On an unknown category
Section titled “On an unknown category”listings › my-tool: unknown category "marketing" (slug "my-tool"); categories insite.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
Section titled “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
Section titled “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.
On an unexpected script
Section titled “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
Section titled “Content”Tags vanish instead of failing
Section titled “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
Section titled “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.
A custom field renders nothing
Section titled “A custom field renders nothing”Values that are objects or arrays are skipped, with no warning. Scalars render.
Development
Section titled “Development”Search finds nothing under pnpm dev
Section titled “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 buildonce to enable search in dev.
Run pnpm build, then pnpm preview. Nothing is wrong with the island.
listings: LISTINGS_SOURCE is unset …
Section titled “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
Section titled “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
Section titled “Deploy”Stripe reports failed deliveries
Section titled “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.
Canonical tags point at the wrong domain
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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.
ADMIN_SECRET is N characters; at least 32 are required
Section titled “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
Section titled “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
Section titled “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
Section titled “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.
Missing secret STRIPE_SECRET_KEY
Section titled “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.
Missing secret STRIPE_WEBHOOK_SECRET
Section titled “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
Section titled “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.
providers.email is "console"
Section titled “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.
Missing secret DEPLOY_HOOK_URL
Section titled “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.
Missing secret GITHUB_TOKEN
Section titled “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
Section titled “Missing secret GITHUB_REPO”The other half of the same pair; see the entry above.
providers.rebuild is "none"
Section titled “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.
Mail is marked sent but never arrives
Section titled “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:
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.
cloudflare E_SENDER_NOT_VERIFIED
Section titled “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
Section titled “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.
404 on /account/, /login/, /api/newsletter/ or /out/
Section titled “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
Section titled “Stripe shows failed deliveries”Covered under Deploy: 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
Section titled “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.
That is not the admin secret / This deployment has no ADMIN_SECRET
Section titled “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.
The hourly jobs did not run locally
Section titled “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:
pnpm build && pnpm exec wrangler devcurl "http://localhost:8787/cdn-cgi/local/scheduled?cron=0+*+*+*+*"wrangler’s log then shows one jobs: expireFeatured=… flushOutbox=… … line. See
Admin.
A submission vanished after the honeypot
Section titled “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.
[zerodirs-files] in the build log when you meant D1
Section titled “[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:
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:
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.
Still stuck
Section titled “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.