Configuration
src/admin.config.ts is the only file you write: five keys naming the facts the CMS cannot derive, and the validator that catches the two typed mistakes.
src/admin.config.ts is the one file you author. Everything in it is a fact about your site that the admin cannot derive: what to call itself, what it is editing, where it commits, which collections it manages, and where the page composer’s building blocks live.
It sits outside src/admin/ deliberately — a file you write cannot ship inside the thing you are adopting — and beside src/content.config.ts, because both are your site’s rather than the framework’s. Exactly one module in the whole tree reads it, which is what lets the values move later without touching a component.
The whole file
import type { AdminConfig } from "./admin/js/adminConfig.ts";
import { siteDomain } from "./site.domain.ts";
export const admin = {
appName: "AstroCraft",
workspace: {
name: "Meridian Coffee",
domain: siteDomain,
},
git: {
remote: "origin",
branch: "main",
},
collections: {
blog: {
name: "blog", // the key in YOUR content.config.ts
label: "Blog",
publicPath: "/blog/", // where an entry lives on the published site
titleField: "title",
descriptionField: "description",
socialImageField: "socialImage",
socialTitleField: "socialTitle",
socialDescriptionField: "socialDescription",
dateField: "pubDate",
updatedField: "updatedDate", // optional — beats dateField for "updated"
draftField: "draft", // optional — marks an entry unpublished
},
},
components: {
sections: "src/components/sections",
layouts: "src/layouts",
},
} satisfies AdminConfig;
satisfies AdminConfig is really checked — pnpm check runs it — so a misspelled key is a type error rather than a surprise at runtime.
appName
The CMS’s own name, not the workspace’s. Change it and every surface follows: the sidebar’s home link, the sign-in sub-line, the invite caveat, the verification email’s subject, the site panel, the review and library rails, the working-tree note and the settings strings.
workspace
name is what your team calls the thing it edits. It is deliberately separate from site.config.ts’s name, which is what a stranger reads in a search result — some adopters want those to differ.
domain is a bare host — example.com, never https://example.com. It becomes https://cms.<domain>/… for invite and verification links and https://<domain>/ wherever a screen points at the live site. It is imported rather than typed, from src/site.domain.ts, because that file is the single statement of the host and Settings → Site rewrites and commits it when an operator connects a domain. Type a literal here and the CMS’s copy stops tracking the site’s.
git
The remote’s name and the default publish branch. There is deliberately no repository URL: the checkout already knows where origin points, and every screen derives it from git remote get-url. A hand-written copy of that was wrong in this very repository once.
branch is only the default publish target. The settings store overrides it per deployment, and Publish reads the store — see Settings.
collections
The heart of it, and the place the no-second-schema promise is either real or aspirational.
Each key is a collection key from your content.config.ts, and the key must equal its own name — both are the same identifier and the validator refuses a mismatch. Every other value is the name of a field that already exists in your schema. This block cannot declare a field, cannot invent one, and cannot disagree with Zod about a type.
| Key | Required | What it names |
|---|---|---|
name |
yes | the collection key in content.config.ts; must equal the object’s own key |
label |
yes | what the sidebar and breadcrumbs call it |
publicPath |
yes | where an entry lives on the published site, e.g. /blog/ — display text, and the base of the editor’s Preview link |
titleField |
yes | the field carrying the entry’s title |
dateField |
yes | the date the list sorts and dates by |
updatedField |
no | a second date; beats dateField for “updated” |
draftField |
no | the boolean marking an entry unpublished |
descriptionField |
no | the search description the SEO tab edits |
socialImageField |
no | the link-preview picture |
socialTitleField |
no | overrides the shared card’s title |
socialDescriptionField |
no | overrides the shared card’s description |
An absent role is the honest answer. If your schema has no second date, leave updatedField out; the editor renders the fields that exist and draws no control addressing a field that does not. This is worth showing with a second collection whose schema spells everything differently:
projects: {
name: "projects",
label: "Projects",
publicPath: "/projects/",
titleField: "title",
descriptionField: "summary", // not `description`
socialImageField: "cover", // not `socialImage`
dateField: "completed", // not `pubDate`
draftField: "draft",
// no updatedField — the schema has no second date
// no social overrides — a project page shares one summary
},
That is the claim in the only form anybody can check by looking: the schema was not bent to suit the editor, the editor was told where to look.
Ask for a collection nobody configured and the build throws, because a typo that renders an empty list looks exactly like a collection with no entries in it.
components
Where the page composer finds its building blocks. Two directories, walked rather than imported, so they are strings read off disk at request time:
components: {
sections: "src/components/sections",
layouts: "src/layouts",
overrides: { /* … */ },
}
Both must exist even if empty, both go through path containment (a .. here would list your file tree on a screen), and both must be under src/ — a composed page imports through @/*, the only alias that reaches an arbitrary directory.
overrides is optional and covered in The component library. Leave a component out entirely and it still appears, drawn with the generic wireframe and whatever props its interface Props declares. You add an entry only to correct or complete what the source cannot state.
images
Optional:
images: { avifQuality: 50 },
What the image library’s Optimise verb encodes at. AVIF’s quality scale is not JPEG’s; 50 is roughly visually lossless for photography, and it is the default if you omit the object. It is configurable because “how hard may the CMS compress my photographs” is your call.
The runtime validator
Beyond the type check, checkAdminConfig runs at import and throws with the file and the key named. It exists for the value-level mistakes a type cannot state, and there are two you will actually make:
- A
domainwritten as a URL.https://example.comtypechecks perfectly as a string and mails a dead link, because the invite builder prefixeshttps://cms.. - A collection key that disagrees with its own
name. Also a perfectly valid object.
Troubleshooting
The build throws naming admin.config.ts and a key. That is the validator. The message names the file and the key; the two cases above are the ones that typecheck.
A collection renders an empty list. Check that its key and name match a collection actually defined in content.config.ts, and that its directory exists under src/content/.
A control appears for a field you removed from the schema. It cannot — but a role pointing at a removed field can. Delete the role from admin.config.ts too; the editor will otherwise address a field Zod no longer declares.
Renaming the app leaves the old name somewhere. It should not; appName is read from one place. If you find a hard-coded string, that is a bug worth reporting.