Silent failures
A stack trace is a gift. The failures on this page do not give you one. They produce a page that renders, a save that appears to work, or an admin screen that returns 404 with an empty dev log.
Each entry below says what you see, what is actually wrong, and the one command that tells the two apart. Read this page when something is strange rather than broken.
Content reverts to seed copy
Section titled “Content reverts to seed copy”You see a section showing the text from your defaults object instead of
what the editor wrote. Often after a deploy that added a field.
What happened is that you added a field to an existing schema without a Zod
.default(). Rows already in D1 do not have the key, so safeParse fails for
the whole section object, and Ounce falls back to your defaults so the page
does not go blank.
Confirm it by reading the stored row:
npx wrangler d1 execute <db> --local \ --command "SELECT key, data FROM page_sections WHERE key = 'benefits'"If the JSON is intact and the page shows something else, this is it.
Fix it by giving every field you add a .default(). For a list item, that
means every field on the item, not just the new one. An append writes a whole
new item, and any required field with no default rejects it.
const featureShape = { title: required, // no default: an append without a title fails loudly description: text.default(''), // default: an append may omit it};A save has no effect
Section titled “A save has no effect”You see the admin showing your change, the preview showing your change, and the public page showing the old text. A hard refresh does not help.
What happened is the cache. Public pages carry s-maxage=600, so
Cloudflare holds them for ten minutes and serves stale for a day after that.
Ounce purges on save, but only when all three of SITE_ORIGIN,
CF_PURGE_ZONE_ID and CF_PURGE_API_TOKEN are set. Missing any one skips the
purge and logs nothing.
Confirm it with curl -sI https://yoursite/ | grep -i cf-cache-status. A
HIT after a save means the purge did not run.
Fix it by setting all three, then check the token has Zone Cache Purge on the right zone.
The admin 404s
Section titled “The admin 404s”You see every route under /admin returning 404. Your own pages are fine.
The dev server log is empty.
What happened is that the admin stylesheet could not resolve its Tailwind
@config path. Astro treats the failed import as a missing route.
Fix it by checking that tailwindcss, postcss, postcss-import and
@tailwindcss/forms are all installed and that postcss.config.mjs exists. If
you wrote your own Tailwind config for the admin, note that @config only works
in a file that uses the @tailwind directives. Switch that file to @import
and the config is ignored, with no error.
Dev blames the wrong file
Section titled “Dev blames the wrong file”You see a cloudflare:workers resolution error pointing at a file that has
nothing to do with the change you just made.
What happened is vite.ssr.noExternal. Leave it true. The named file is
never the problem.
A field saves into the wrong shape
Section titled “A field saves into the wrong shape”You see a field that always reads back empty, or a nested object appearing in your data that you never declared.
What happened is a dot in the field’s name. Ounce splits input names on
dots to rebuild nested objects, so { name: 'post.code' } saves as
{ post: { code: ... } } and your schema’s post.code stays empty forever.
Fix it with post_code. There is no warning for this and there cannot
easily be one, because a dot is how legitimate nesting is expressed.
An edit disappears
Section titled “An edit disappears”You see one editor’s save overwriting another’s, on a different tab of the same page.
What happened, or rather what stops it happening, is the baseline hash. Every
tabbed form submits its untouched entries along with the edited one. Ounce sends
a __baseline.<key> hash with each and skips writing any entry whose stored
value still matches its baseline.
This is worth knowing because a custom admin screen that posts to the same endpoint without a baseline will clobber. Include it.
A value is never shown
Section titled “A value is never shown”You see a field an editor keeps filling in that has no effect on the site.
What happened is that your template ignores it. Ounce stores whatever the schema accepts and records it in the revision trail, and it has no way to know your component never reads it.
Confirm it by grepping your template for the field name. Nothing else will tell you.
Related: a field whose type your template does not handle renders as nothing. See Rendering fields: supporting a field type in the admin and supporting it in a template are two separate jobs, and only the first one is done for you.
Disabled items misbehave
Section titled “Disabled items misbehave”You see either an enabled: false item rendering, or a filter you wrote
that never removes anything.
What happened is that stripDisabled already ran. It removes every array
item with enabled === false, at any depth, inside loadView. Your template
receives data that has already been filtered, so a second filter is dead code
and an item that still shows is not carrying the flag you think it is.
The preview stops scrolling
Section titled “The preview stops scrolling”You see the preview pane loading correctly but never jumping to the section you clicked.
What happened is missing wrapper markup. preview.js finds a section with
document.querySelector('[data-ounce-section="benefits"]'). That attribute is
written by Sections.astro, which ships in the package. A hand-rolled section
loop that omits it breaks this and nothing else, so it looks like a preview bug.
Fix it by rendering through @ouncepage/core/Sections.astro rather than
your own loop.
A beacon never fires
Section titled “A beacon never fires”You see a dashboard with no data and a page that renders perfectly.
What happened is the Content-Security-Policy. The browser refuses the script and logs to its own console. Your Worker logs stay clean, no request fails, and nothing on the page looks wrong.
Analytics beacons are also suppressed in astro dev, so an empty dashboard
while you are developing is expected rather than a symptom. Set
beaconInDev: true in your site config when you need to prove a beacon loads
locally, and take it back out.
Confirm it against a production build, because the CSP is not sent in astro dev:
npx wrangler devcurl -sI http://localhost:8787/ | grep -i content-security-policyFix it by adding the host to the csp block in your config, or, if it is a
plugin’s beacon, by checking that the plugin declares it under
analytics.hosts.
Analytics counts traffic that is not real
Section titled “Analytics counts traffic that is not real”You see paths in your analytics that no visitor could have reached: admin screens, a page you only ever opened locally, a slug that was never deployed.
What happened is that the tracker read <link rel="canonical"> rather than
location. Most privacy-first trackers do, because it is how they collapse
?utm_source= and www. variants onto one path. Your canonical is built from
Astro.site, which is the production origin in every environment, so a page
loaded from localhost reports itself as a production page.
Confirm it by loading a local page with the network panel open and reading the beacon’s own query string:
curl -s http://localhost:4321/ | grep -o '<link rel="canonical"[^>]*>'If the canonical says production and the tracker reports that path, this is it.
Fix it by not shipping the beacon outside production, which Ounce does for
you unless you ask for it with beaconInDev. A second source of the same symptom is an unmatched URL that falls
through to the public catch-all: a 404 renders the beacon and its canonical is
whatever was asked for, so a bot probing /wp-admin is recorded as a visit to
/wp-admin. Admin paths are excluded from the catch-all; everything else is
real 404 traffic and worth seeing.
Staging is the harder case, because beaconInDev does not help there. A
deployed preview environment is a production build, so the beacon fires, and if
it shares SITE_ORIGIN with production its canonical is a production URL.
Every staging pageview is then recorded against production.
The trap is that the obvious guard does not work. Setting the tracker’s site id
to an empty string in the staging vars block looks like it disables the
beacon, and it does not, because a value passed to the plugin factory in
ounce.config.ts wins over the environment variable whenever the environment
has no plugin: row yet:
// compiled into every environment, and the live value wherever no row existsplugins: [fathom({ siteId: 'ABC123' })],
// reads the per-environment variable insteadplugins: [fathom()],Confirm it by asking the environment’s own database what it has stored:
wrangler d1 execute <db> --remote --env staging \ --command "SELECT key, data FROM settings WHERE key LIKE 'plugin:%'"No rows means that environment is running on whatever ounce.config.ts
declared, not on its own variables.
Fix it by keeping environment-specific values out of ounce.config.ts
entirely. That file is compiled into every environment. Site ids, account ids
and anything else that differs per environment belong in wrangler.jsonc vars
or in secrets, which each plugin’s settings helper already reads.
A menu renders with no links
Section titled “A menu renders with no links”You see an empty header on a page that plainly has a menu configured, and the same menu rendering correctly on the public site.
What happened is that resolveMenus was called without the page-id-to-slug
map. Menu items store a page reference rather than a path, so with no map every
item resolves to an empty href and is dropped. The menu is not empty; it is
unresolvable.
Confirm it by counting the links in both renders:
curl -s http://localhost:4321/ | grep -c 'class="jump-link'curl -s http://localhost:4321/admin/preview/1 | grep -c 'class="jump-link'Fix it by passing await pageRoutes() as the fifth argument, and the page’s
section anchors as the fourth. loadView does both; anything that builds a
View by hand has to as well. See Navigation.
A file route shadows a CMS page
Section titled “A file route shadows a CMS page”You see an editor’s changes never reaching the public page, while the preview shows them correctly.
What happened is that src/pages/privacy.astro exists and outranks the
injected catch-all. The CMS page with slug /privacy is still there, still
editable, and never served.
Confirm it by listing src/pages/ and comparing against the slugs in the
admin.
A utility class does nothing
Section titled “A utility class does nothing”You see a utility class passed into a component doing nothing, while the same class works elsewhere.
What happened is Tailwind specificity. If the component already sets
text-gray-500 on that element, your text-white and its text-gray-500 have
equal specificity and source order decides. Source order in the compiled sheet
is not your call.
Fix it by wrapping the component’s own defaults in :where(), which drops
their specificity to zero and lets any caller override them.
Works here, breaks elsewhere
Section titled “Works here, breaks elsewhere”You see nothing at all, until someone installs the package into a different project.
What happened is dependency hoisting. A package that imports something it
does not declare still resolves, because the consuming project happens to have
it at the root of node_modules.
Confirm it by listing the package’s own imports against its manifest:
npm ls jose ultrahtmlEvery bare specifier the engine imports should be attributed to
@ouncepage/core, not to the site. See How it fits
together for why the workspace symlink alone does not
enforce this.