Skip to content

Routing

A fresh Ounce project has no src/pages/ and no src/middleware.ts, and does not need either. The integration injects every route and adds its own middleware. This page covers what that gives you and how to work alongside it.

Nineteen admin routes, three public ones, and the MCP endpoint.

Pattern Does
/[...path] Renders a page through your page component
/404 Renders the not-found view on its own
/media/[...key] Streams an R2 object
/admin, /admin/pages, /admin/settings, and sixteen more The admin
/_ounce/mcp The MCP server. Always mounted, answers only once a plugin supplies an MCP provider

The catch-all does four things in order. It loads the page for the requested path. If there is no page, it looks for a redirect left behind by a rename and sends a 301. Failing that, it loads the not-found view and answers 404. On a hit it sets Cache-Control: public, max-age=0, s-maxage=600, stale-while-revalidate=86400, so Cloudflare serves the page from cache for ten minutes and keeps serving a stale copy for a day while it refreshes.

It refuses admin paths outright, with a bare 404 rather than your not-found view. A rest parameter sorts last in Astro, so /admin/anything-unmatched would otherwise reach the catch-all and be answered by the public site, complete with its analytics beacon and a canonical naming the admin URL.

Saving in the admin purges the affected paths, but only when SITE_ORIGIN, CF_PURGE_ZONE_ID and CF_PURGE_API_TOKEN are all set. Miss one and the purge is skipped in silence, which looks exactly like a save that did not work.

/media/[...key] answers with Cache-Control: public, max-age=31536000, immutable and the object’s own ETag. Ounce writes content-addressed keys, so a replaced image is a new key and the year-long cache never lies.

Create src/pages/ and put files in it. Astro ranks a file route above an injected one, so your file wins.

That cuts both ways. A file at src/pages/privacy.astro shadows the CMS page whose slug is /privacy, completely and with no warning. The editor keeps editing the page in the admin, the preview keeps showing the changes, and the public site keeps serving your file.

The integration registers it with order: 'pre', so it runs before yours. In order, it:

  1. Redirects an apex host to its www. canonical, when SITE_ORIGIN names one.
  2. Calls authenticate() and sets Astro.locals.editor.
  3. Answers 403 to any /admin path when there is no editor.
  4. Runs the rest of your app.
  5. Sets the security headers.
  6. Marks /admin and /_ounce responses private, no-store and noindex.

Write your own src/middleware.ts if you need one. It runs after Ounce’s and sees locals.editor already populated.

Astro’s own security.checkOrigin defaults to true for output: 'server', and every admin save is a form POST. A request without a same-origin Origin header is refused before any Ounce code runs, with a bare 403 and no body.

This never shows up in a browser, which sets the header itself. It shows up the first time you script a save:

Terminal window
curl -X POST http://localhost:4321/admin/pages/1 \
-H 'Origin: http://localhost:4321' \
--data 'heading=Hello'

Ounce has no origin check of its own, so there is nothing to configure here. The 403 is Astro’s and the header is the fix.

It fires only when SITE_ORIGIN parses and its hostname starts with www.. With SITE_ORIGIN unset, as in local dev, there is no redirect and no X-Robots-Tag.

That last part matters on staging. Point a staging environment’s SITE_ORIGIN at production and every staging response claims a canonical host it does not have, so it answers noindex correctly but a save there purges production’s cache.

Guarding on pathname.startsWith('/admin') is not enough on its own. /%61dmin, //admin and /ADMIN all reach the same routes. Ounce decodes up to four times, collapses repeated slashes and lowercases before comparing, so those all count as admin paths and get the 403.

Ounce builds two policies and sends one of them, but only on a production build. import.meta.env.PROD is false in astro dev, so a missing host does not show up until you deploy. Check it against the built worker, where the flag is true.

The adapter writes its own Wrangler config during the build, so that is the one to run, and it needs to be pointed at the same local D1 the dev server has been writing to:

Terminal window
astro build
wrangler dev -c dist/server/wrangler.json --persist-to "$PWD/.wrangler/state"
curl -sI http://localhost:8787/ | grep -i content-security-policy

Both flags matter. Without -c you are running whatever wrangler.jsonc points at, which before the first build is nothing. Without --persist-to the worker gets a fresh empty database and every page answers 500 with no such table: pages, because the paths inside dist/server/wrangler.json resolve relative to dist/server.

.dev.vars resolves from there too, so the copy in your project root is not read. Copy it across if the built worker needs it. Signing in is not what it is for: the dev editor is gated on import.meta.env.DEV, which a build sets false, so /admin answers 403 here whatever you put in the file. Check the admin against astro dev and the CSP against this.

Stop the dev server first, or start it again afterwards. A build and a dev server no longer fight over one Vite cache directory, because the integration gives each command its own, but only one process can hold the local D1 file.

The public policy combines three sources. Ounce’s own directives, every host your installed plugins declare, and the csp block in your config:

src/site/ounce.config.ts
export default defineSite({
csp: {
img: ['https://api.mapbox.com'],
frame: ['https://booking.example.com'],
},
});

Keys are script, style, img, font, connect and frame. Each starts at 'self' and your hosts are appended. There is no way to remove 'self'.

A plugin contributes through the same six keys, its own hosts, so installing one that needs an origin does not make its hosts your problem. An analytics provider can also declare script, connect and img for its beacon. Both land in the same directives, deduplicated.

Values are emitted as written, so a 'sha256-...' goes through as readily as an origin. That is how a plugin ships an inline script without 'unsafe-inline', which is never added.

Four directives are fixed and take no configuration: frame-ancestors 'none', base-uri 'none', object-src 'none' and form-action 'self'. The last one is 'self' rather than 'none' because the admin posts its forms back to the same origin, and 'none' would break every save.

The admin gets a tighter policy with no external hosts at all, except the one your logo service uses if you configured one.

Every response carries X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, X-Frame-Options: DENY and a Permissions-Policy that turns off camera, microphone, geolocation and payment. Production adds Strict-Transport-Security: max-age=31536000; includeSubDomains.

X-Frame-Options: DENY goes on every response, the admin’s included, so no page on the site can be embedded in a frame anywhere. Your own tooling is not an exception.

The admin preview looks like a counter-example and is not. It never navigates. preview.js fetches /admin/preview/[id] with fetch() and assigns the HTML to frame.srcdoc, and a frame that is never pointed at a URL never triggers the header. Anyone tempted to simplify that code into frame.src = endpoint will break the preview instantly, and the browser will blame a header rather than the edit.

frame-src governs the frames your own templates embed, a booking widget or a map. It starts at 'self' because that is what the browser falls back to when the directive is absent, so listing a host adds to it rather than replacing it.