Install
The short way
Section titled “The short way”npm create astro@latest example -- --template minimal --no-install --no-gitcd examplenpx ounce-initnpm installnpm run db:migrate:localnpm run devounce-init writes the whole install: astro.config.mjs with the integration
and the resolver settings, a wrangler.jsonc with both bindings, the PostCSS
and Tailwind configs, a tsconfig.json with the paths below, src/env.d.ts,
Ounce’s migration plus a seeded home page, and a src/site/ and
src/templates/ thin enough to delete a piece at a time.
It refuses to overwrite anything. --dry-run lists what it would write and
--force overwrites. Working against a local checkout instead, change
@ouncepage/core to file:../ounce before npm install, and the .npmrc
above stops being necessary.
The rest of this page is the same install done by hand, and what each part is for.
Requirements
Section titled “Requirements”| Astro | 7.0 or later |
| Zod | 4.0 or later |
| Node | 22.12 or later |
| Adapter | @astrojs/cloudflare, output: 'server' |
| Bindings | D1 as DB, R2 as MEDIA |
Ounce declares its own runtime dependencies and pulls them in for you. What it
takes as peer dependencies, and so uses your project’s copy of, is astro,
zod, tailwindcss, postcss, postcss-import and @tailwindcss/forms. The
last four are the toolchain that compiles the admin stylesheet; see admin
styles below.
-
Install.
Terminal window npm install @ouncepage/core astro @astrojs/cloudflare zodnpm install -D tailwindcss postcss postcss-import @tailwindcss/forms autoprefixer -
Add the integration.
astro.config.mjs import { defineConfig } from 'astro/config';import cloudflare from '@astrojs/cloudflare';import ounce from '@ouncepage/core/integration';export default defineConfig({site: 'https://example.com',output: 'server',adapter: cloudflare({ imageService: 'passthrough' }),integrations: [ounce({config: './src/site/ounce.config.ts',page: './src/templates/Page.astro',notFound: './src/templates/NotFound.astro',}),],vite: {ssr: { noExternal: true },resolve: { dedupe: ['astro', 'zod'] },},});All three paths are resolved against your project root.
configdefaults to./src/site/ounce.config.ts.pageis required: it names the component that renders aView, and Ounce uses it for your public pages and for the admin preview.notFoundis optional and falls back topage; what it receives differs in one way worth reading before you write it.The
dedupematters wherever two copies of Astro or Zod can resolve at once. A path dependency guarantees that; a registry install in a workspace can still arrange it. The symptom isastro checkexhausting a four gigabyte heap rather than anything naming a duplicate, so leave it in. -
Declare the bindings.
wrangler.jsonc {"name": "example","compatibility_date": "2026-09-01","compatibility_flags": ["nodejs_compat"],"d1_databases": [{"binding": "DB","database_name": "example","database_id": "...","migrations_dir": "migrations"}],"r2_buckets": [{ "binding": "MEDIA", "bucket_name": "example-media" }],"vars": { "SITE_ORIGIN": "https://example.com" }}The binding names
DBandMEDIAare fixed. Ounce reads them fromcloudflare:workersdirectly, so renaming them breaks it.There is no
mainkey, and adding one is the first mistake most people make. The adapter writes the worker entry itself and emits its owndist/server/wrangler.jsonat build time. Naming amainin the source config before that file exists makes the Cloudflare Vite plugin refuse to start: “The provided Wrangler config main field … doesn’t point to an existing file”. Leave it out, in every environment. -
Point the types at Ounce.
src/env.d.ts /// <reference types="astro/client" />/// <reference types="@ouncepage/core/env" />That one line declares the bindings Ounce reads, the bindings its bundled plugins read, and
App.Locals.editor. Add your own bindings to the same file by merging intoCloudflare.Env. -
Point TypeScript at the same copies Vite resolves.
tsconfig.json {"extends": "astro/tsconfigs/strict","compilerOptions": {"baseUrl": ".","paths": {"astro": ["node_modules/astro"],"astro/*": ["node_modules/astro/*"],"zod": ["node_modules/zod"],"zod/*": ["node_modules/zod/*"]}}}The Vite
dedupesettles what the bundler loads. This settles what the type checker loads. Skip it andastro checkreports type errors between two structurally identical copies of the same interface. -
Write the config. See the site config.
-
Run the migrations. See database and bindings.
File layout
Section titled “File layout”Directorysrc
- env.d.ts two reference lines
Directorysite
- ounce.config.ts the only file the integration reads
- settings.ts one entry per global singleton
- sections.ts one entry per section type
Directorytemplates/
- Page.astro renders a View
- NotFound.astro renders the not-found View
- sections.ts key to component
Directorycomponents/ your section components
- …
Directoryfields/ your custom field types, if any
- …
Directoryplugins/ your local plugins, if any
- …
Directorymigrations/ yours and Ounce’s, in one directory
- …
- astro.config.mjs
- wrangler.jsonc
There is no src/pages/ and no src/middleware.ts in that tree, and you do not
need either. Ounce brings its own routing and its own middleware. If you add a
src/pages/about.astro it still wins over Ounce’s catch-all, because a file
route outranks an injected one.
Nothing under src/ is required to sit at those exact paths except
ounce.config.ts, and even that is wherever you point the integration. The
layout above is the one the rest of this documentation uses.
The integration
Section titled “The integration”Three things, all at astro:config:setup.
It injects the routes. The admin ones, so /admin/pages,
/admin/settings/[key] and the rest exist without a file in your src/pages/.
And the public ones: /[...path] renders a page through your page component
and falls through to a redirect lookup then the not-found view, /404 renders
the not-found view on its own, and /media/[...key] streams your R2 objects
with a long immutable cache header.
Do not create your own files at the admin paths; an injected route and a file route at the same pattern is a collision Astro resolves in favour of your file, and you will get a 404 or a blank page with no error.
It adds the middleware, ahead of yours. It authenticates through Cloudflare
Access and sets Astro.locals.editor, refuses /admin to anyone it cannot
identify, redirects an apex host to its www. canonical when SITE_ORIGIN says
so, and sets the security headers, including a Content-Security-Policy built
from the hosts your installed plugins declare plus the csp block in your
config.
Write your own src/middleware.ts if you want one; it runs after Ounce’s and
sees locals.editor already set.
It registers the Vite virtual modules. ounce:config re-exports everything
your config returns; ounce:page and ounce:notfound resolve to the two
components you named. Admin routes import from the first, and so can you:
import { loadView, listPages, brand } from 'ounce:config';Admin styles
Section titled “Admin styles”You import nothing. The admin shell imports its own stylesheet, which carries
its own @config and never touches your site’s Tailwind build.
What your project must provide is the toolchain to compile it: Tailwind 3,
PostCSS, postcss-import and @tailwindcss/forms, with a postcss.config.mjs
Astro will pick up.
import postcssImport from 'postcss-import';import tailwindcss from 'tailwindcss';import autoprefixer from 'autoprefixer';
export default { plugins: [postcssImport, tailwindcss, autoprefixer],};The admin’s palette is restricted to transparent, current, white,
black, gray (mapped to neutral), red, green and amber. Nothing else
exists. A bg-blue-500 in a plugin’s admin screen compiles to nothing, with no
error. Use gray for structure, red and green for outcomes, amber for anything an
AI wrote.
Troubleshooting
Section titled “Troubleshooting”A broken Tailwind @config path. If the admin stylesheet cannot resolve its
config, every route that imports it returns a 404 rather than an error, and the
dev server log stays empty. If the whole admin disappears at once, check this
before anything else.
vite.ssr.noExternal. Leave it true. With it off, dev fails with a
cloudflare:workers resolution error naming a file that has nothing to do with
the problem.
A Vite cache shared between commands. astro dev and astro build
otherwise write to the same node_modules/.vite, and a build run while the dev
server is up leaves every later dev request at 500 with a missing file under
deps_ssr/. The integration gives each command its own cache directory, so
this is handled. It steps aside the moment you set vite.cacheDir yourself, and
then the collision is yours to key apart.