cms-adapters
v0.1.0
Published
A JSON-first, type-safe headless CMS engine that plugs into any project through adapters, with a framework-agnostic frontend SDK.
Maintainers
Readme
CMS Adapter
A JSON-first, type-safe headless CMS engine for Node. You declare a schema in TypeScript (or build it visually in the browser); it gives you a typed content API, an HTTP layer, and a generated admin UI. Storage, file handling, and authorization are swappable adapters.
Status: early (
v0.1.0). The core engine, API, admin, and SDK are covered by tests and usable today. APIs may still change before1.0. See Scope & limitations for what it does and does not do.
Install
npm install cms-adaptersThe package ships two entry points so the same library serves both sides of your stack:
| Import | Use it from | Runtime deps |
| --- | --- | --- |
| cms-adapters | Node backend — the CMS engine, HTTP layer, and admin UI | hono, zod (and better-sqlite3 only if you use sqliteStorage) |
| cms-adapters/sdk | Any frontend — React, Vue, Svelte, vanilla, etc. | none (just fetch) |
The SDK is a self-contained, framework-agnostic fetch client with zero
dependencies, so it drops into any browser app. better-sqlite3 is an
optional peer dependency — install it only if you use the SQLite storage
adapter; everything else (including memoryStorage) works without it.
Both ESM and CommonJS builds are published, with full TypeScript types.
What it is
You declare a schema; the engine gives you validation, a typed content API, an HTTP layer, and a generated admin UI. Where data lives, how files are stored, and how requests are authorized are swappable adapters, so the core stays the same whether you back it with SQLite locally or something else in production.
import {
apiKeyAuth,
asset,
boolean,
createCMSApp,
localAssets,
number,
richText,
slug,
sqliteStorage,
text
} from "cms-adapters";
const { app, cms } = createCMSApp({
schema: {
project: {
label: "Projects",
fields: {
title: text({ required: true }),
slug: slug({ source: "title", required: true }),
cover: asset(),
year: number({ min: 2000 }),
featured: boolean({ defaultValue: false }),
body: richText()
}
}
},
storage: sqliteStorage("./cms.db"),
assets: localAssets({
dir: "./public/uploads",
publicPath: "/uploads",
maxFileSize: 5 * 1024 * 1024,
allowedMimeTypes: ["image/jpeg", "image/png", "image/webp"],
allowedExtensions: ["jpg", "jpeg", "png", "webp"]
}),
auth: apiKeyAuth({
key: process.env.CMS_KEY ?? "dev-key",
protect: "all"
}),
adminPath: process.env.CMS_ADMIN_PATH ?? "/cms-console"
});app is a standard Hono app (web-standard fetch handler), so you
serve it however you run Hono. With the Node server adapter:
import { serve } from "@hono/node-server";
serve({ fetch: app.fetch, port: 4000 });
// admin UI: http://localhost:4000/cms-console
// REST API: http://localhost:4000/content/projectRun (local development of this repo)
npm install
npm run devOpen the generated admin UI:
http://localhost:4000/cms-consoleUse dev-key to log in unless you started the server with CMS_KEY=....
Set CMS_ADMIN_PATH=/your-private-path to move the admin UI again.
Content is persisted in a local SQLite database (cms.db), and uploaded assets are
stored under public/uploads.
Standalone CMS server
Use the CLI when your website or app is not a Node backend, or when you want the CMS to run as its own service. Any project that can call HTTP can use it:
npx cms-adapters dev cms.config.mjs --port 4000Your config can export a full CMS config:
import {
apiKeyAuth,
localAssets,
sqliteStorage,
text
} from "cms-adapters";
export default {
schema: {
note: {
label: "Notes",
fields: {
title: text({ required: true })
}
}
},
storage: sqliteStorage("./cms.db"),
assets: localAssets({ dir: "./public/uploads", publicPath: "/uploads" }),
auth: apiKeyAuth({ key: process.env.CMS_KEY, protect: "all" }),
adminPath: "/studio"
};Or export only schema; the CLI supplies standalone defaults:
import { text } from "cms-adapters";
export const schema = {
note: {
label: "Notes",
fields: {
title: text({ required: true })
}
}
};Schema-only mode uses:
- SQLite storage at
CMS_DBor./cms.db - Local uploads at
CMS_UPLOAD_DIRor./public/uploads - Public asset path
CMS_UPLOAD_PATHor/uploads - API-key auth with
CMS_KEYordev-key - Admin path
CMS_ADMIN_PATHor/admin
Then any stack can consume the CMS API:
Laravel / Django / Rails / Go / .NET / static frontend
-> http://localhost:4000/content/noteCMS Setup (visual schema builder)
You can define content types two ways — they're equivalent:
- In code — the
schemayou pass tocreateCMSApp(shown above). - In the browser — open the admin UI and click ⚙ CMS Setup. Create a content type, add its fields visually, and hit save. It's stored in the database and takes effect immediately — a new type appears in the menu with its own form, no code and no restart.
Schema edits are persisted as a single reserved record through the same storage
adapter, so anything you build in the UI survives restarts. The same thing is
available over HTTP via PUT /schema/:type and DELETE /schema/:type (protected by
auth like any other write).
Type Safety
The schema flows into the type system. create, update, get, and list are all
typed from your field definitions — no manual interfaces:
await cms.create("project", {
data: {
title: "Nike Campaign", // ✅ required string
year: "2026" // ❌ type error: expected number
// slug omitted // ✅ optional on input — derived from `title`
}
});
const project = await cms.get("project", id);
project.data.title; // typed as stringInput vs. output types differ on purpose: a slug with a source, or any field with
a defaultValue, is optional when you create (the core fills it in) but always
present when you read it back.
Fields
| Builder | Stored as | Notable options |
| --- | --- | --- |
| text() | string | required, min, max, pattern, format: "email" \| "url", options, unique, defaultValue |
| richText() | string (HTML) | same as text |
| slug() | string | source (derive from another field), required, unique (always unique) |
| number() | number | required, min, max, defaultValue |
| boolean() | boolean | required, defaultValue |
| date() | string (ISO) | required |
| asset() | string (asset id) | required |
| assetList() | string[] | required |
| reference(type) | string (entry id) | validated to exist on write |
| json() | any | defaultValue |
What the core does for you on every write:
- Defaults —
defaultValueis applied when a field is omitted. - Slugs —
slug({ source })derives and normalizes the slug from its source. - References —
reference()values are checked to point at an existing entry. - Uniqueness —
slug(and anyuniquefield) is enforced within its type. - Validation — every field is checked against a schema-derived validator, and
unknown
datakeys are rejected instead of being stored silently.
Querying
// list with status filter, ordering, and pagination
await cms.list({
type: "project",
status: "published",
orderBy: { field: "year", direction: "asc" },
limit: 10,
offset: 0
});
// look up by slug (uses the content type's slug field, whatever it's named)
await cms.findBySlug("project", "nike-campaign");Over HTTP:
curl "http://localhost:4000/content/project?status=published&sort=year&order=asc&limit=10"
curl "http://localhost:4000/content/project/slug/nike-campaign"Filter operators
A where value is either a bare value (strict equality) or an object of
comparison operators applied against a field inside data:
await cms.list({
type: "project",
where: {
year: { gte: 2020, lt: 2030 }, // range
title: { contains: "Nike" }, // substring match
featured: true // equality (bare value)
}
});| Operator | Meaning |
| --- | --- |
| (bare value) | strict equality |
| eq / ne | equal / not equal |
| gt / gte / lt / lte | numeric or lexical comparison |
| in | value is one of an array |
| contains | string contains substring |
Populating references
reference() fields store the target entry's id. Pass populate on any read
to resolve them into the full entry — true for every reference field, or a
list of field names:
await cms.get("project", id, { populate: true });
await cms.list({ type: "project" }, { populate: ["lead"] });
await cms.findBySlug("project", "nike-campaign", { populate: true });Over HTTP, use the populate query param (true or a comma-separated list):
curl "http://localhost:4000/content/project/$ID?populate=true"
curl "http://localhost:4000/content/project?populate=lead,cover"HTTP API
| Method | Path | Action |
| --- | --- | --- |
| GET | /schema | the live schema JSON |
| PUT | /schema/:type | create/replace a content type (CMS Setup) |
| DELETE | /schema/:type | remove a content type (CMS Setup) |
| GET | /content/:type | list (supports status, sort, order, limit, offset, populate) |
| POST | /content/:type | create |
| GET | /content/:type/:id | read one (supports populate) |
| GET | /content/:type/slug/:slug | read one by slug (supports populate) |
| PATCH | /content/:type/:id | update (shallow-merges data) |
| DELETE | /content/:type/:id | delete |
| GET | /content/:type/:id/versions | list past versions (newest first) |
| GET | /content/:type/:id/versions/:version | read one past version |
| POST | /content/:type/:id/versions/:version/restore | restore a past version |
| GET / POST | /assets | list / upload assets |
| GET | /uploads/:filename | serve an asset |
curl -X POST http://localhost:4000/content/project \
-H "Content-Type: application/json" \
-H "Authorization: Bearer dev-key" \
-d '{ "status": "published", "data": { "title": "Nike Campaign" } }'Errors
Errors come back with a status code and a stable code:
| Code | Status | Meaning |
| --- | --- | --- |
| validation_error | 422 | data failed validation (includes issues: { field, message }[]) |
| unknown_content_type | 404 | the type is not in the schema |
| not_found | 404 | entry does not exist |
| conflict | 409 | uniqueness violation (e.g. duplicate slug) |
| unauthorized | 401 | missing/invalid auth |
| forbidden | 403 | authenticated but not permitted |
Auth
Auth is an optional adapter. Without one, everything is open. The admin UI includes
a session-only API-key login: when an endpoint returns 401, it asks for a key and
sends it as Authorization: Bearer <key> on future admin requests.
The built-in apiKeyAuth protects writes by default and leaves reads public. For
deployment, prefer protect: "all" so schema, content, assets, and writes all
require the key:
auth: apiKeyAuth({
key: process.env.CMS_KEY, // string or string[]
protect: "all", // "writes" (default) | "all"
scheme: "Bearer", // header scheme; "" for a raw key
header: "authorization" // header to read
});Bring your own by implementing the AuthAdapter contract (authorize(request) →
{ allow }); the API layer calls it for every request.
Before deploying: set
CMS_KEYto a strong secret and do not ship a hardcoded fallback (the?? "dev-key"above is for local development only). With noauthadapter configured, every endpoint is public — only run that locally.
Move the built-in admin UI off the predictable /admin path with adminPath.
This is not a replacement for auth, but it keeps the obvious route from being a
scanner target:
createCMSApp({
schema,
storage,
auth: apiKeyAuth({ key: process.env.CMS_KEY, protect: "all" }),
adminPath: process.env.CMS_ADMIN_PATH ?? "/cms-console"
});Lifecycle Hooks
Hooks run inside the core, around persistence — the place for derived fields, webhooks, search indexing, or audit logs.
createCMSApp({
schema, storage,
hooks: {
// runs on create & update, BEFORE validation; return a value to replace data
beforeChange({ operation, type, data, existing }) {
return { ...data, title: String(data.title).trim() };
},
// runs AFTER a successful create/update — fire webhooks, reindex, etc.
async afterChange({ operation, entry }) {
await fetch("https://hooks.example.com", {
method: "POST",
body: JSON.stringify({ operation, entry })
});
},
// throw to veto a delete
beforeDelete({ entry }) {
if (entry.status === "published") throw new Error("Unpublish before deleting.");
},
afterDelete({ entry }) {
console.log("removed", entry.id);
}
}
});beforeChangeruns after defaults/slugs are applied but before validation, so anything it returns is still validated.before*hooks can throw to abort the operation;after*hooks run only once the change is persisted.- Every hook also receives
context— the value an auth adapter resolved for the request (see Auth) — so you can enforce ownership or write audit logs:
beforeChange({ data, context }) {
return { ...data, ownerId: (context as { userId?: string })?.userId };
}Versioning
Set history: true and every update first snapshots the previous state. The
snapshots are persisted through the same storage adapter — no extra setup.
createCMSApp({ schema, storage, history: true });
await cms.listVersions("project", id); // newest first
await cms.getVersion("project", id, 2); // a single past version
await cms.restoreVersion("project", id, 2); // re-apply as a new updateOver HTTP:
curl "http://localhost:4000/content/project/$ID/versions"
curl "http://localhost:4000/content/project/$ID/versions/2"
curl -X POST "http://localhost:4000/content/project/$ID/versions/2/restore" \
-H "Authorization: Bearer dev-key"restoreVersion applies the snapshot as a normal update, so it bumps the
version and runs your hooks like any other change.
Frontend SDK
A framework-agnostic fetch client — works in React, Vue, Svelte, or vanilla JS.
Import it from the dependency-free cms-adapters/sdk entry point:
import { createCMSClient } from "cms-adapters/sdk";
const cms = createCMSClient({ baseUrl: "http://localhost:4000" });
const projects = await cms.findMany("project", { status: "published", limit: 10 });
const one = await cms.findBySlug("project", "nike-campaign", { populate: true });
const history = await cms.listVersions("project", one.id);Share your schema's type to get the same end-to-end inference the server has — the
client's data is then fully typed, no manual interfaces:
import type { schema } from "./cms.config"; // `satisfies CMSSchema`
const cms = createCMSClient<typeof schema>({ baseUrl });
const projects = await cms.findMany("project"); // projects[0].data.title is typedThe client mirrors the engine: findMany, findOne, findBySlug (all accept
{ populate }), create, update, delete, and listVersions / getVersion /
restoreVersion.
Core Shape
Every content entry uses the same shell — only data changes between types:
{
"id": "entry_id",
"type": "project",
"status": "draft",
"data": {},
"meta": { "createdAt": "...", "updatedAt": "...", "version": 1 }
}Scope & limitations
What this is good for, and what it deliberately is not — so you can decide if it fits before adopting it:
It handles: schema-driven content types (in code or via the admin UI), typed CRUD, validation, slugs, defaults, uniqueness, references, filtering/pagination, optional versioning, lifecycle hooks, local file uploads with size/type guards, and a single-key auth adapter.
It does not (yet) handle:
- Multi-user roles/permissions. Auth is a single API key by default; finer access
control means implementing your own
AuthAdapter. - Horizontal scale. The bundled storage adapters (
sqliteStorage,memoryStorage) assume one process owns the data. For multiple instances, back it with a shared-database storage adapter of your own. - A media CDN.
localAssetswrites files to local disk. For object storage (S3/R2/etc.), implement theAssetAdaptercontract. - Migrations. Changing a field's type on an existing type does not rewrite stored entries; plan schema changes accordingly.
The adapter contracts (StorageAdapter, AssetAdapter, AuthAdapter) are the
intended extension points for all of the above. StorageAdapter can optionally
provide transaction() for atomic write units and close() for adapters that
hold open resources; sqliteStorage implements both.
Folder Structure
src/
core/ CMS kernel: schema, fields, validation, errors, content engine
adapters/
api/ Hono API/admin adapter
assets/ Asset storage adapters
auth/ Auth adapters (apiKeyAuth)
storage/ Content storage adapters (sqlite, memory)
admin/ Built-in admin UI
sdk/ Frontend client (browser-safe, zero deps)
cli.ts Standalone CMS server command
app.ts createCMSApp convenience factory
index.ts Public package exports (the `cms-adapters` entry point)
examples/ Consumers only
tests/ node:test suites
tsup.config.ts Library build: emits dist/ (ESM + CJS + .d.ts)The rule: core owns CMS logic, adapters connect it to the outside world, and the
core builds every entry shell — adapters only persist.
Develop & Build
npm test # node:test suites (core, API/auth, hooks, schema, assets, storage, CLI)
npm run typecheck # tsc --noEmit
npm run build # tsup -> dist/ (ESM + CJS + .d.ts for `.` and `/sdk`)npm publish runs prepublishOnly (typecheck + tests + build) automatically, and
only the dist/ folder and README.md are shipped.
