Skip to content

Troubleshooting

Markdown

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

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. 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.

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.

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.

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.

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.

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.

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.

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

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 once to enable search in dev.

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

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

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.

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.

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.

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.

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.

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.

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.

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.

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

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.

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.

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.

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.

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

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.

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:

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.

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.

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.

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.

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.

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:

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.

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.

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.