AstroCraft — home
AstroCraft
Buy a licence
All documentation

Mount it in your Astro config

One integration line, one actions export, one tsconfig block — and why output must be 'server' rather than a preference you get to have.

Last updated

Astro finds pages under src/pages/ and actions at src/actions/index.ts and nowhere else, so a package that owns routes has to hand them over through the integration hooks Astro provides for exactly this. That is what adminPackage is. It injects the admin’s routes, its session guard, and the build-time check that keeps those routes on demand.

Three edits, in files you already have.

1 · astro.config.mjs

import { execFileSync } from "node:child_process";

import { defineConfig, envField } from "astro/config";
import node from "@astrojs/node";
import tailwindcss from "@tailwindcss/vite";

import { adminPackage, adminSession } from "./src/admin/mount.ts";

// The build stamps the commit it was built from, so a deploy is observable with no per-platform
// deploy API. `unknown` rather than a failed build for a tree that is not a checkout.
const buildRev = (() => {
  try {
    return execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim();
  } catch {
    return "unknown";
  }
})();

export default defineConfig({
  output: "server", // not a preference — see below
  adapter: node({ mode: "standalone" }), // or cloudflare / netlify / vercel — one line
  integrations: [adminPackage],

  // Sessions follow the account store: `undefined` unless ASTROCRAFT_DB_URL is set, in which case
  // the adapter's filesystem default is replaced by the same database. This has to be host config
  // rather than part of the integration — adapters claim the session slot in their own setup hook
  // and run first, so an integration arrives too late to take it.
  session: adminSession(),

  // Astro writes the content store to `.astro/` in dev and to `cacheDir` everywhere else, so
  // `astro sync` and Vitest (which resolves through `getViteConfig` as dev) otherwise fill and
  // read DIFFERENT files. Leave this out and the suite reports your collections as empty —
  // thirty-odd failures, and no error message anywhere pointing at this line.
  cacheDir: "./.astro/",

  // Both are optional as VALUES; the SCHEMA is structurally required. The invite and verification
  // actions import these from `astro:env/server`, so without this block the build does not warn,
  // it dies:
  //   [MISSING_EXPORT] "RESEND_API_KEY" is not exported by "\0astro:env/server".
  env: {
    schema: {
      RESEND_API_KEY: envField.string({ context: "server", access: "secret", optional: true }),
      MAIL_FROM: envField.string({ context: "server", access: "secret", optional: true }),
    },
  },

  vite: {
    plugins: [tailwindcss()],
    // The stamp above, as a compile-time constant.
    define: { "import.meta.env.BUILD_REV": JSON.stringify(buildRev) },
  },
});

That is a real working config, taken from an actual install rather than assembled from the parts. Four things in it are load-bearing and each produces a failure that names something else entirely:

  • cacheDir — without it the test suite reports your collections as empty.
  • env.schema — without it the build dies with [MISSING_EXPORT].
  • session: adminSession() — without it a database-backed store still leaves sessions on the filesystem, so a redeploy logs everybody out even though their accounts survived.
  • define for BUILD_REV — without it publishing works and never confirms.

2 · src/actions/index.ts

Add one line to whatever is already there:

export { server } from "../admin/actions/index.ts";

If you already export your own actions, spread rather than replace — two export { server } from lines are a redeclaration, and export * twice silently keeps one:

import { server as admin } from "../admin/actions/index.ts";
import { server as mine } from "./mine.ts";

export const server = { ...admin, ...mine };

This is the one file you hand-write rather than copy, which is why it uses a relative specifier. Astro has no injectAction hook, so the mount cannot do it for you.

3 · tsconfig.json

Copy the paths block across. The package’s own imports use @admin/*, @config/*, @layouts/*, @components/*, @assets/* and @/* internally.

@layouts/* resolves to src/admin/layouts/ — the dashboard’s, not yours. If you write a page through the composer, it must not use that alias. Composed pages import through @/*, which is the only alias reaching an arbitrary directory.

Why output: 'server' is not negotiable

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 included — straight past the session guard, and look perfect while doing it.

Under output: 'server', on demand is the default and public pages opt back out. That inversion is the load-bearing decision of the whole rendering model, and you do not have to remember it: two build-time hooks refuse both directions of the mistake. Set output: 'static' and the build fails, naming the route.

The practical consequence for your own site is that every page under src/pages/ that is part of your public site now needs export const prerender = true. Miss it and the page still works — it just renders per request and silently costs you a CDN cache hit.

---
export const prerender = true;
---

Astro’s own detection regex is /^\s*export\s+const\s+prerender\s*=\s*(true|false);?/m, so indentation is fine. What defeats it is a // in front.

The build stamp

Keep one line in whatever page answers /:

<meta name="build-rev" content={import.meta.env.BUILD_REV} />

The config above already defines BUILD_REV from git rev-parse HEAD, so this is the only half left to you — and it has to be a page the CMS can fetch over HTTP, which in practice means whatever answers /.

After a publish, the review screen polls your production site until that stamp is a descendant of the commit it pushed, which is what turns “publishing…” into “Your changes are live ✓”. Without it, publishing still works; you just never get told when it landed.

Restart your dev server

Astro reads its configuration once, at startup. A dev server that was already running before you mounted the CMS keeps serving a site with no CMS in it, and typically fails with a missing-file error naming a file that is plainly there. Stop it and start it again.

One related cost worth knowing: the route table is scanned off disk at config load rather than per request. A page added under src/admin/pages/ while astro dev is running answers 404 until you restart. Editing an existing one still hot-reloads normally.

Check it

pnpm check && pnpm build

pnpm check catches the env.schema and paths mistakes. pnpm build is the half no test replaces — the two rendering rules are build-time hooks, so a route on the wrong side of the static/on-demand line is caught there or nowhere.

Next: Configuration, the one file you write yourself.