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

@natilon/cms-server

v0.11.0

Published

Express-based CMS server with pluggable adapters for content, media, auth, and build.

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 vite

Usage

// 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 (requires vite).
  • { mode: "auto", distDir, sourceDir }vite-dev when NODE_ENV !== "production", static otherwise.

startCmsServer(opts)

Convenience: builds the app, mounts the admin UI, listens. Returns { server, app, adapters }.

Subpath exports

  • @natilon/cms-server/media-url — pure createMediaUrl(mediaConfig) factory for building CDN URLs in your site code. No express dependency, 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.mjs

Walks 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 run build-index in 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 resolve hook backed by D1, KV, Algolia, Pagefind, or another external index.