Skip to content
AstroCraft Docs
On this theme

Troubleshooting

Most of what follows is documented in the source at the point it can go wrong. These are the failures worth knowing before you hit them, ordered roughly by how hard they are to diagnose from the symptom.

The build refuses with “SITE_URL is unset or still the placeholder”

Working as intended. A production deploy will not ship the example.com placeholder, because that one value feeds canonical URLs, OG tags, JSON-LD, the sitemap, robots.txt and llms.txt — six things one wrong value poisons at once, none of them visibly broken in review.

Set SITE_URL in your host’s build environment. If you are on a host that is not Netlify or Vercel, the gate reads DEPLOY_ENV=production — set that only in production, or preview deploys start failing on the placeholder too.

The build dies with [MISSING_EXPORT] "MAIL_FROM" is not exported

You removed MAIL_FROM from the env.schema block in astro.config.mjs. It is optional as a value — the CMS runs without one, invites just cannot be sent — but structurally required, because the admin’s actions import it from astro:env/server. Put the line back and leave the value unset.

adminPackage refuses the build

Under output: "static", and it names the route it refused for. This is not a preference to override: the admin’s session guard is middleware, and middleware runs at request time only for an on-demand route. For a prerendered one it runs at build time and never sees a visitor, so a statically built admin would ship its HTML, drafts and all, straight past the guard while looking perfect.

Keep output: "server". The site itself is unaffected — 31 of 32 routes still prerender.

Sign-in says “Email or password is incorrect” and no password works

Check trailingSlash in astro.config.mjs. It must be "ignore".

The admin serves three URL shapes Astro will not answer under "always": the sign-in endpoint the form POSTs, the image store’s query form, and the emailed invite and verify links whose last segment is a token. All three 404 — and the sign-in one is invisible, because the auth form renders every non-ok, non-429 response as a credentials error. A routing 404 reaches the person as a wrong password.

A new admin page 404s in dev

Restart the dev server. The admin’s route table is read from disk once at config load rather than re-scanned per request, so a page added under src/admin/pages/ while astro dev is running is not in it. Editing an existing one still hot-reloads normally.

This does not apply to your own src/pages/ — that is Astro’s file router.

The CMS suite reports every collection as empty

cacheDir in astro.config.mjs. Astro writes the content store to .astro/ in dev and to cacheDir everywhere else, so without an explicit cacheDir: "./.astro/", astro sync and Vitest (which resolves through getViteConfig as dev) fill and read different files. There is no error message anywhere pointing at it.

Publish is refused

Each refusal names a different person’s next step:

Rejected — the push was refused and somebody at a terminal has to reconcile. The CMS cannot pull. Auth — the push had no credentials; that needs an operator with access to the server’s environment. No remote — the branch does not exist on the remote yet. Build failed — the gate ran your build command and it did not pass. Read the tail, fix, publish again. Licence — a key is required to publish, and only to publish. Settings → Licence. See Licences and activation.

The CMS refuses to sync — “dirty tree”

Something tracked is missing or modified in the checkout. In a container, the usual cause is .dockerignore excluding a tracked path: it shows up as a deletion in git status and blocks every content operation while the public site still looks perfectly fine. .dockerignore should list only paths git already ignores.

A static asset 404s between two publishes

The publish build gate wrote over the dist/ the running process is serving from. Set ASTROCRAFT_PUBLISH_OUT_DIR so the gate’s build writes elsewhere. This is harmless under a container that rebuilds on boot and wrong on a plain node ./dist/server/entry.mjs on a box.

A collapsing region never animates

Collapse needs two things that lint, check, build and test all pass without: the grid item needs min-h-0, and it must carry no padding of its own. overflow-clip is not a scroll container, so unlike overflow-hidden it does not zero min-height: auto; and padding floors the track below min-height. Get either wrong and the row silently stays at full height — only the visibility flip shows.

Put your padding on an element inside the slot.

A leading-* utility has no effect

tailwind-merge treats font-size as conflicting with leading and silently drops the leading-*. Pin the line height on the font size instead: text-base/(--row-line).

A filter or toggle does nothing

The hide rules for the work filter, the gallery mode toggle and the journal layout toggle are unlayered on purpose. A display: none written inside @layer components loses to a card’s own flex utility, because a later layer wins regardless of specificity. If you moved those rules into a layer, move them back out.

A parallax photo is cropped at the sides

The shift is too large. object-cover pays for vertical oversize in horizontal crop, and the parallax-child utility sizes the child to the exact minimum, 100% / (1 - 2s). At a 5% shift that is 111% and costs 10% of the width; a 20% shift costs proportionally more. Reduce --parallax-shift.

A photo under a fixed, scroll-driven frame paints as flat grey

It needs decoding="sync". A scroll-driven transform puts the photo on a compositor layer, and an async decode can land after that layer’s first paint. PageHero documents it.

Parallax leaves content offset, or a fade ends invisible

The browser does not support scroll timelines, so animation-timeline was dropped and the animation ran once, time-based. Entrances survive this because they end at identity; scroll-only shapes do not. There is a @supports not (animation-timeline: view()) block that makes seven of them inert instead — if you added a new scroll-only animation, add it to that list.

Smooth scroll lags the wheel and never settles

Native smooth scroll is fighting Lenis. html.lenis { scroll-behavior: auto } in global.css is the fix and it ships — check you have not removed it. Lenis writes scrollTop every frame, and a smooth <html> makes the browser animate toward each of those writes instead of landing on them.

The build throws “two entries share order N”

Two projects claim the same order. It is thrown rather than tolerated because the loader’s own order is filesystem order, so duplicates would silently swap on every build — and order fixes both the grid slot and the pager’s neighbours.

The CMS shows a component as “no props found”

The prop walker reads a self-contained interface Props. It refuses an interface Props extends … outright, because the members it could see would look like the whole list and a prop nobody saw would vanish on the next save. It also cannot turn a readonly array annotation into a control.

Spell the props out, use a mutable array type, and if a prop genuinely cannot be authored from a page, declare it in overrides in src/admin.config.ts — a declared prop reads differently from an unknown one, and the inspector distinguishes the two.

The composer finds no sections

components.sections in src/admin.config.ts must be "src/components/Sections" — capital S. The composer walks that literal path, and a case-sensitive filesystem finds nothing under the lowercase spelling.

An entry is “published” but not on the site

Published means committed to the publish branch. Live means the deployed build contains it. The editor’s pill distinguishes them by reading <meta name="build-rev"> off the live site and asking git whether your save is an ancestor of it. A stamp that does not look like a real revision — a truncated tag, an unexpanded CI variable — reads as “cannot be shown”, which rests at building.

If it never reaches live, the site did not rebuild. Check your host picked up the push.

pnpm test finds no tests and fails

Working as intended. The discovery runner fails on an empty result so a check cannot go missing unnoticed. If you are seeing it unexpectedly, you have probably moved or renamed src/ files out from under the glob.

A helper works in the app but crashes under pnpm test

pnpm test runs under plain Node with type stripping, which resolves neither tsconfig path aliases nor extensionless TypeScript imports, and knows nothing about astro:*. Tested helpers use relative, extension-bearing specifiers and import nothing from the Astro runtime. Follow the pattern in src/js/contact.ts.

The stylesheet is enormous

164,378 bytes is the shipped size, and the dev catalog is 18,803 of it. Delete src/components/Sections/UiCatalog/ and src/pages/examples/ when you have finished picking primitives.

Do not reach for @source not instead — it is not build-mode conditional, so it strips the demo rules in astro dev too and the catalog silently stops animating. A further 27,653 bytes exists only because Tailwind scans src/admin/, and @source not "../admin" unstyles the CMS in production, because the admin imports the same global.css and has no sheet of its own. That one needs an upstream fix.

Still stuck

The CMS’s own Troubleshooting covers the admin in more depth, and How it works explains the parts a symptom might be pointing at. For the theme itself, the repo’s wiki/ is a maintained knowledge base with a page per subsystem — start at wiki/index.md.