@natilon/cms-server
v0.11.0
Published
Express-based CMS server with pluggable adapters for content, media, auth, and build.
Maintainers
Readme
@natilon/cms-server
Express-based CMS server with pluggable adapters for content storage,
media, auth, and build-status. Designed to be mounted from any Node
process or — via @natilon/astro-cms — directly into
astro dev.
The server is glue code: routes, JSON wiring, error handling. All
behavior comes from adapter factories you instantiate from your
cms.config.mjs.
Install
npm i @natilon/cms-server
# Optional, only needed for the dev-mode admin UI middleware:
npm i -D viteUsage
// scripts/admin-server.mjs
import path from "path";
import { fileURLToPath } from "url";
import { startCmsServer } from "@natilon/cms-server";
import cmsConfig, { publicConfig } from "../cms.config.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT_DIR = path.join(__dirname, "..");
await startCmsServer({
config: cmsConfig,
publicConfig,
rootDir: ROOT_DIR,
realm: "My Site Admin",
adminUi: {
mode: "auto", // vite-dev when NODE_ENV !== "production", static otherwise
distDir: path.join(ROOT_DIR, "node_modules/@natilon/admin-ui/dist"),
sourceDir: path.join(ROOT_DIR, "node_modules/@natilon/admin-ui"),
},
});API
createCmsServer({ config, rootDir, publicConfig?, realm? })
Returns { app, adapters }. Routes are already mounted; you can add
your own middleware before/after and call app.listen() yourself.
mountAdminUi(app, adminUi)
Mounts the admin SPA under /admin. Modes:
{ mode: "static", dir }— serves a prebuilt SPA bundle.{ mode: "vite-dev", root, base? }— dev middleware with HMR (requiresvite).{ mode: "auto", distDir, sourceDir }—vite-devwhenNODE_ENV !== "production",staticotherwise.
startCmsServer(opts)
Convenience: builds the app, mounts the admin UI, listens. Returns
{ server, app, adapters }.
Subpath exports
@natilon/cms-server/media-url— purecreateMediaUrl(mediaConfig)factory for building CDN URLs in your site code. Noexpressdependency, safe to import from Astro components.@natilon/cms-server/adapters— direct access to each adapter factory if you want to compose your own server.
Routes
| Method | Path | Purpose |
| ------- | -------------------------------------- | ------------------------ |
| GET | /api/config | sanitized publicConfig (allowlisted before auth) |
| GET | /api/collections | collection summaries |
| GET | /api/collections/:c | list entries |
| GET | /api/collections/:c/:file | read entry |
| PUT | /api/collections/:c/:file | update entry |
| POST | /api/collections/:c | create entry |
| DELETE | /api/collections/:c/:file | delete entry |
| GET | /api/assets | grouped local-assets list (allowlisted before auth) |
| GET | /api/assets/:folder | files in one local folder |
| GET | /api/media/folders | CDN folders (proxy) |
| GET | /api/media/folder/:folder | CDN files (proxy) |
| POST | /api/media/upload | upload to CDN (proxy) |
| POST | /api/publish | git commit + push |
| GET | /api/publish/status | pending changes |
| GET | /api/deploy/status | Netlify deploy status |
Adapters
All under src/adapters/. Each is a pure factory; no module-scoped state.
| Factory | Implements | Notes |
| ------------------------ | ----------------- | -------------------------------------- |
| createFsJsonContent | ContentAdapter | JSON files on disk + git push |
| createLocalAssetsMedia | MediaAdapter | legacy src/assets/ file picker |
| createCdnProxyMedia | MediaAdapter | proxies CloudFront/S3 listing + upload |
| createBasicAuth | AuthAdapter | HTTP Basic + HMAC-SHA256 JWT for media |
| createNetlifyBuild | BuildAdapter | Netlify deploy status |
| createMediaUrl | (URL helper) | cdnBase + resize-prefix → URLs |
JSDoc contracts in src/adapters/types.mjs. Swap in your own
implementation by passing different adapter instances; the server
glue is agnostic.
Collection listing & the content index
The problem
Listing a collection traditionally fetches every entry's full JSON via GraphQL or a fallback REST approach (one request per file). On Cloudflare Workers this exceeds the 50-subrequest limit. On GitHub's GraphQL, large collections return 502 errors. The solution: a lightweight per-collection _index.json manifest containing only entry metadata (id, slug, lang, collection, title, and brief meta). The server reads one manifest per collection instead of fetching every entry.
How _index.json works
Each collection maintains a manifest at <pagesDir>/<collection>/_index.json:
{
"entries": [
{
"id": "entry-1",
"slug": "my-first-post",
"lang": "en",
"collection": "blog",
"title": "My First Post",
"file": "entry-1.json",
"meta": { /* custom fields from metaFields */ }
}
]
}The index is maintained incrementally: createPage, writePage, and deletePage upsert or remove entries. On batch operations, writeBatch regenerates the _index.json in the same commit. Normal CMS edits keep the index in sync automatically — no rebuild needed.
Configuration: the content.list block
In cms.config.mjs, configure listing behavior under content.list:
content: {
provider: "github",
// ...
list: {
strategy: "index", // default; reads _index.json
rebuild: "build", // "build" (default) | "lazy"
indexFile: "_index.json", // manifest filename
resolve: undefined, // optional: custom listing function
},
}| Option | Values | Description |
| --------- | ----------------------- | ----------- |
| strategy | "index" | Built-in strategy: reads the _index.json manifest. Only option currently shipped. |
| rebuild | "build" (default), "lazy" | "build": server never cold-rebuilds an index at request time. If missing, returns empty and logs a warning to run the CLI. Safest for serverless (avoids subrequest blowups). "lazy": if an index is missing, the server bootstraps it with one GraphQL request and persists it. Convenient for small/self-hosted setups, but risky on very large collections (GraphQL may fail). |
| indexFile | string | Override the manifest filename (default: _index.json). |
| resolve | async (collection, { sortConfig }) => entries[] \| null | Optional: bring your own listing logic. Completely replaces the built-in strategy. Return null for unknown collections. Plug in D1, KV, Algolia, Pagefind, or any external index here — this is the scale/search extension point. |
Out-of-band rebuild: the build-index CLI
Regenerate all _index.json manifests locally:
npx natilon-cms build-index --config ./cms.config.mjsWalks the local pagesDir, regenerates _index.json for every collection, and prints per-collection entry counts. Commit the result.
When to run:
- Once when adopting the index on an existing repo.
- After bulk imports or migrations.
- After content changed outside the CMS (e.g., direct git edits).
- Recommended in CI/deployment for serverless: build and commit the index locally or in your CI pipeline, so the Worker only ever reads it (pairs with
rebuild: "build").
Quick decision guide
- Small site or self-hosted:
rebuild: "lazy"keeps setup simple. - Serverless (Cloudflare Workers) or large collections (default, recommended): Use
rebuild: "build"and runbuild-indexin your build/deploy pipeline. Commit the index and the Worker reads it once per request — zero surprise subrequests. - Real full-text search, faceting, or huge scale: Provide a
resolvehook backed by D1, KV, Algolia, Pagefind, or another external index.
