npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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.

license: MIT

Status: early (v0.1.0). The core engine, API, admin, and SDK are covered by tests and usable today. APIs may still change before 1.0. See Scope & limitations for what it does and does not do.

Install

npm install cms-adapters

The 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/project

Run (local development of this repo)

npm install
npm run dev

Open the generated admin UI:

http://localhost:4000/cms-console

Use 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 4000

Your 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_DB or ./cms.db
  • Local uploads at CMS_UPLOAD_DIR or ./public/uploads
  • Public asset path CMS_UPLOAD_PATH or /uploads
  • API-key auth with CMS_KEY or dev-key
  • Admin path CMS_ADMIN_PATH or /admin

Then any stack can consume the CMS API:

Laravel / Django / Rails / Go / .NET / static frontend
  -> http://localhost:4000/content/note

CMS Setup (visual schema builder)

You can define content types two ways — they're equivalent:

  1. In code — the schema you pass to createCMSApp (shown above).
  2. 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 string

Input 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:

  • DefaultsdefaultValue is applied when a field is omitted.
  • Slugsslug({ source }) derives and normalizes the slug from its source.
  • Referencesreference() values are checked to point at an existing entry.
  • Uniquenessslug (and any unique field) is enforced within its type.
  • Validation — every field is checked against a schema-derived validator, and unknown data keys 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_KEY to a strong secret and do not ship a hardcoded fallback (the ?? "dev-key" above is for local development only). With no auth adapter 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);
    }
  }
});
  • beforeChange runs 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 update

Over 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 typed

The 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. localAssets writes files to local disk. For object storage (S3/R2/etc.), implement the AssetAdapter contract.
  • 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.