Skip to content

Install

Terminal window
npm create astro@latest example -- --template minimal --no-install --no-git
cd example
npx ounce-init
npm install
npm run db:migrate:local
npm run dev

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

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.

  1. Install.

    Terminal window
    npm install @ouncepage/core astro @astrojs/cloudflare zod
    npm install -D tailwindcss postcss postcss-import @tailwindcss/forms autoprefixer
  2. 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. config defaults to ./src/site/ounce.config.ts. page is required: it names the component that renders a View, and Ounce uses it for your public pages and for the admin preview. notFound is optional and falls back to page; what it receives differs in one way worth reading before you write it.

    The dedupe matters 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 is astro check exhausting a four gigabyte heap rather than anything naming a duplicate, so leave it in.

  3. 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 DB and MEDIA are fixed. Ounce reads them from cloudflare:workers directly, so renaming them breaks it.

    There is no main key, and adding one is the first mistake most people make. The adapter writes the worker entry itself and emits its own dist/server/wrangler.json at build time. Naming a main in 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.

  4. 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 into Cloudflare.Env.

  5. 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 dedupe settles what the bundler loads. This settles what the type checker loads. Skip it and astro check reports type errors between two structurally identical copies of the same interface.

  6. Write the config. See the site config.

  7. Run the migrations. See database and bindings.

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

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';

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.

postcss.config.mjs
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.

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.