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

@dalgoridim/headless-cms

v0.10.0

Published

Database-agnostic, inline-edit headless CMS engine for React / Next.js apps

Downloads

1,984

Readme

@dalgoridim/headless-cms

Database-agnostic, inline-edit headless CMS engine for React / Next.js apps.

Content lives in your database and is editable inline on the live site by an authenticated admin. Persistence, auth, and storage are fully pluggable — swap Firestore for Postgres, Cloudinary for S3, Firebase auth for NextAuth, without touching your UI.

Harness, not framework. Zero runtime dependencies; the editing components are headless (you own all markup and styling); your content shape stays unconstrained. You build a thin skin over the primitives (see Styling) — the package never pushes a look onto your site. The design rationale and internals live in ARCHITECTURE.md.

Live example: dalgoridim.com runs on this package. Try it: anyone can toggle edit mode and change the content inline, right on the page — but saves are gated, so only the authenticated owner's edits actually persist. Your changes are yours alone to play with.

Install

npm install @dalgoridim/headless-cms

react / react-dom are the only peer deps. Every backend SDK is an optional peer — install just what your chosen stack needs. The two walkthroughs below list the exact extras:

  • Setup with Firebasefirebase, firebase-admin, cloudinary.
  • Setup with Postgresdrizzle-orm, pg, drizzle-kit. The package ships PostgresDataAdapter, but drizzle-orm/pg are optional peers (loaded only when you import /adapters/postgres), so you install them yourself.
  • Other storage: @aws-sdk/client-s3 + @aws-sdk/s3-request-presigner for S3; @react-oauth/google for the Google sign-in button.

Subpath exports

Server-only code never leaks into the client bundle. Import from the right entry:

| Entry | Contents | | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | @dalgoridim/headless-cms | Shared types only (safe anywhere) | | .../client | PageProvider, usePageContext (field edits + collection add/remove/reorder), ContentEditSpan, EditableImage, useMarkdownEditor, CmsAuthProvider, AnonymousEditProvider, useCmsAuth | | .../server | createCmsHandlers, createAdminGate, loadItemMap, resolveRelations | | .../adapters/firestore | FirestoreDataAdapter | | .../adapters/postgres | PostgresDataAdapter — Drizzle-backed; you bring the schema (needs drizzle-orm + pg) | | .../storage/{cloudinary,s3,local} | Client upload adapters — pure fetch, safe in client components | | .../storage/{cloudinary,s3,local}/server | Server signers — pull in SDKs, server-only | | .../auth/firebase | firebaseAuth (server gate) | | .../auth/firebase/client | FirebaseAuthProvider, useFirebaseAuth | | .../auth/google | googleAuth (server gate), verifyGoogleIdTokenFirebase-free Google sign-in | | .../auth/google/client | GoogleAuthProvider, useGoogleAuth, GoogleSignInButton | | .../auth/nextauth | nextAuthAuth, customAuth |

Core interfaces

import type {
  DataAdapter,
  AuthAdapter,
  StorageAdapter,
} from "@dalgoridim/headless-cms";
  • DataAdapter — backend CRUD over a neutral Query.
  • AuthAdapterverifyRequest(req) resolves an identity; the gate decides (auth).
  • Storage is split in two so server SDKs never reach the client bundle: ClientStorageAdapter (upload, from .../storage/<x>) and ServerStorageAdapter (sign, from .../storage/<x>/server).

How it fits together

A Next.js App Router install wires four pieces; only the first two change with your backend:

| # | Piece | with Firebase | with Postgres | | --- | ----------------------------------------------------------------- | -------------------------------- | -------------------------------------------- | | 1 | DataAdapter — your database | FirestoreDataAdapter (shipped) | PostgresDataAdapter (shipped; bring schema) | | 2 | AuthAdapter — who may save | firebaseAuth | googleAuth / nextAuthAuth / custom | | 3 | Admin routecreateCmsHandlers({ data, auth }) | identical | identical | | 4 | ClientPageProvider + auth provider + headless primitives | identical | identical |

Follow one of the two end-to-end setups below, then the shared Client setup.

Setup with Firebase (Firestore)

npm install @dalgoridim/headless-cms firebase firebase-admin cloudinary

1. Admin route — data + auth

// app/api/admin/[collection]/[id]/route.ts
import { createCmsHandlers } from "@dalgoridim/headless-cms/server";
import { FirestoreDataAdapter } from "@dalgoridim/headless-cms/adapters/firestore";
import { firebaseAuth } from "@dalgoridim/headless-cms/auth/firebase";

const data = new FirestoreDataAdapter({
  credentials: {
    projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
    clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
    privateKey: process.env.FIREBASE_PRIVATE_KEY,
  },
});
const auth = firebaseAuth({
  adminEmails: process.env.ADMIN_EMAILS!.split(","),
});

export const { GET, PATCH, PUT, DELETE } = createCmsHandlers({ data, auth });

2. Storage sign route (for image uploads)

// app/api/admin/sign/route.ts
import { createCmsHandlers } from "@dalgoridim/headless-cms/server";
import { cloudinarySign } from "@dalgoridim/headless-cms/storage/cloudinary/server";
// reuse the same `data` + `auth` from step 1
export const POST = createCmsHandlers({
  data,
  auth,
  storage: cloudinarySign({
    cloudName: process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME,
    apiKey: process.env.CLOUDINARY_API_KEY,
    apiSecret: process.env.CLOUDINARY_API_SECRET,
    folder: "uploads", // the signer is authoritative for this folder
  }),
}).sign;

3. Client providers

"use client";
import { PageProvider } from "@dalgoridim/headless-cms/client";
import { FirebaseAuthProvider } from "@dalgoridim/headless-cms/auth/firebase/client";
import { cloudinaryStorage } from "@dalgoridim/headless-cms/storage/cloudinary";
import { auth, googleProvider } from "@/lib/firebase/config";

const storage = cloudinaryStorage({ signEndpoint: "/api/admin/sign" });

export function Providers({ initialItems, children }) {
  return (
    <FirebaseAuthProvider auth={auth} googleProvider={googleProvider}>
      <PageProvider initialItems={initialItems} storage={storage}>
        {children}
      </PageProvider>
    </FirebaseAuthProvider>
  );
}

That's the whole Firebase wiring → continue at Client setup.

Setup with Postgres (Drizzle)

PostgresDataAdapter does all the CRUD; you own the Drizzle schema and migrations. You declare your tables, run Drizzle Kit, then hand the adapter a connection + the schema map. drizzle-orm / pg are optional peers you install.

npm install @dalgoridim/headless-cms drizzle-orm pg
npm install -D drizzle-kit

1. Declare your schema

// lib/db/schema.ts — typed Drizzle tables, keyed by collection name
import { pgTable, text, integer, timestamp } from "drizzle-orm/pg-core";

export const projects = pgTable("projects", {
  id: text("id").primaryKey(),
  title: text("title"),
  views: integer("views"),
  tags: text("tags").array(),
  order: integer("order"),
  createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
  updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(),
});

export const schema = { projects } as const;

Every field is a real column — no schemaless fallback, no extra catch-all, so schema drift surfaces as an error instead of a silent JSONB write.

2. Generate + run migrations (Drizzle Kit)

// drizzle.config.ts
import { defineConfig } from "drizzle-kit";
export default defineConfig({
  schema: "./lib/db/schema.ts",
  out: "./lib/db/migrations",
  dialect: "postgresql",
  dbCredentials: { url: process.env.DATABASE_URL! },
});
npx drizzle-kit generate   # write a migration from the schema
npx drizzle-kit migrate    # apply it

DDL is entirely yours; the package never touches your schema.

3. Admin route — data + auth

PostgresDataAdapter does all the DML over your schema (mapping the neutral Query onto Drizzle). Hand it a connectionString (or a pool / a Drizzle db) plus the schema map:

// app/api/admin/[collection]/[id]/route.ts
import { createCmsHandlers } from "@dalgoridim/headless-cms/server";
import { PostgresDataAdapter } from "@dalgoridim/headless-cms/adapters/postgres";
import { googleAuth } from "@dalgoridim/headless-cms/auth/google";
import { schema } from "@/lib/db/schema";

const data = new PostgresDataAdapter({
  connectionString: process.env.DATABASE_URL!,
  schema,
});
const auth = googleAuth({
  clientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!,
  adminEmails: process.env.ADMIN_EMAILS!.split(","),
});

export const { GET, PATCH, PUT, DELETE } = createCmsHandlers({ data, auth });

Every persisted field must be a declared column — writing an unknown field throws rather than silently dropping it. (Add a sign route exactly as in the Firebase setup, step 2, if you want image uploads.)

Prefer to share one Drizzle db across your app? Pass { db, schema } instead of connectionString. To back a different ORM (Prisma, Kysely, …) entirely, implement the seven-method DataAdapter yourself — the engine only ever speaks that interface.

4. Client providers

"use client";
import { PageProvider } from "@dalgoridim/headless-cms/client";
import { GoogleAuthProvider } from "@dalgoridim/headless-cms/auth/google/client";
import { cloudinaryStorage } from "@dalgoridim/headless-cms/storage/cloudinary";

const storage = cloudinaryStorage({ signEndpoint: "/api/admin/sign" });

export function Providers({ initialItems, children }) {
  return (
    <GoogleAuthProvider
      clientId={process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!}
      adminEmails={["[email protected]"]}
    >
      <PageProvider initialItems={initialItems} storage={storage}>
        {children}
      </PageProvider>
    </GoogleAuthProvider>
  );
}

→ continue at Client setup.

Client setup (any backend)

Everything from here is identical regardless of backend. PageProvider persists deferred edits with PUT ${apiBasePath}/{collection}/{id} (default apiBasePath = /api/admin). Edit mode comes from useCmsAuth().isEditing, fed by whichever auth provider you mounted. Pass notify to surface your own toasts — the default logs to the console so the package carries no toast dependency.

Hydrate initialItems on the server (see One model: items), then drop the headless primitives into your markup:

The editing primitives (headless)

ContentEditSpan — inline-editable text

Wires contentEditable, reads/writes the field, and exposes edit state via data-* attributes. It ships no styling and no markup language — supply your own via className and renderValue.

import { ContentEditSpan } from "@dalgoridim/headless-cms/client";

<ContentEditSpan
  collection="pages"
  itemId="hero" // the item's id; a singleton "section" has a stable id
  fieldKey="title"
  as="h1" // any element/tag; defaults to "span"
  renderValue={(raw) => raw} // turn the stored string into nodes (default: plain text)
  className="my-heading"
>
  Default title
</ContentEditSpan>;

State is exposed as attributes so you can style with plain CSS (or Tailwind data-[...] variants):

| Attribute | Present when | | ------------------- | ---------------------------------- | | data-cms-editable | always (the primitive is mounted) | | data-cms-editing | the current user is in edit mode | | data-cms-focused | the field is actively being edited |

[data-cms-focused] {
  outline: 2px solid var(--accent);
  border-radius: 4px;
}
[data-cms-editing]:hover {
  outline: 1px dashed var(--accent);
  cursor: text;
}

Want rich text? Pass a renderValue that parses your own syntax (markdown, a custom mini-language, etc.) into React nodes — the package no longer dictates one.

EditableImage — upload / external-URL via render-prop

Manages file picking, object URLs, external-URL validation, and the pending-upload queue. You render the chrome:

import { EditableImage } from "@dalgoridim/headless-cms/client";

<EditableImage
  collection="pages"
  itemId="hero"
  fieldKey="image"
  src={item.image}
>
  {({
    isEditing,
    saving,
    hasError,
    openFilePicker,
    setExternalUrl,
    imgProps,
  }) => (
    <div className="relative">
      <img {...imgProps} alt="" />
      {isEditing && (
        <button onClick={openFilePicker} disabled={saving}>
          Replace
        </button>
      )}
    </div>
  )}
</EditableImage>;

Omit the render-prop child and it falls back to a bare unstyled <img>.

useMarkdownEditor — headless markdown editing

State + a selection-aware insert command. Bring your own modal, toolbar, icons, and preview (e.g. react-markdown):

import { useMarkdownEditor } from "@dalgoridim/headless-cms/client";

function Editor({ initialValue, onSave }) {
  const md = useMarkdownEditor({ initialValue, onSave });
  return (
    <>
      <button onClick={() => md.insert("**", "**", "bold")}>Bold</button>
      <textarea
        ref={md.textareaRef}
        value={md.value}
        onChange={(e) => md.setValue(e.target.value)}
      />
      <span>{md.charCount} chars</span>
      <button onClick={md.save}>Save</button>
    </>
  );
}

One model: items (and "sections" are just singleton items)

There is a single data model. Every collection is a list of items, hydrated via one prop, initialItems:

<PageProvider
  initialItems={{
    pages: [await data.fetchById("pages", "hero")],        // a singleton "section"
    projects: await data.fetchCollection("projects"),       // a list
  }}
>

For more than one collection, loadItemMap handles concurrent reads, queries, default merging, and explicit error fallbacks without owning your content policy:

import { loadItemMap } from "@dalgoridim/headless-cms/server";

const initialItems = await loadItemMap(data, {
  pages: {
    defaults: [{ id: "hero", title: "Default title" }],
    merge: "byId",
    fallback: [{ id: "hero", title: "Default title" }],
  },
  projects: {
    query: { orderBy: [{ field: "order", direction: "asc" }] },
    fallback: [],
  },
});

fallback is used only when a read throws. A successful empty query stays empty. Without a fallback the original adapter error propagates. With merge: "byId", stored fields overlay matching defaults and newly stored ids are appended.

  • A singleton section (a hero, an about block) is simply an item with a stable, known id. Edit its fields inline with ContentEditSpan / EditableImage (deferred — see below), addressing it by that id.
  • A list is the same items, plus ops to change which items exist and their order.

Field edits are deferred (edit many, then Save All)

ContentEditSpan / EditableImage call editField, which updates local state and marks the item dirty; nothing is written until you save. Drive a Save bar from usePageContext:

const { hasUnsavedChanges, saving, saveAll } = usePageContext();
// <button disabled={!hasUnsavedChanges || saving} onClick={saveAll}>Save</button>

saveAll() uploads any pending images, then PUTs (upserts) each dirty item. saveItem(collection, id) saves just one.

Item ops are immediate (add / remove / reorder)

To let an admin change which items exist and their order, usePageContext exposes optimistic, self-persisting ops:

import { usePageContext } from "@dalgoridim/headless-cms/client";

const { items, createItem, updateItem, deleteItem, reorderItems } =
  usePageContext();

// add — PUT (upsert) with a generated id; returns the new id
await createItem("projects", {
  title: "New project",
  order: items.projects?.length ?? 0,
});

// patch fields immediately — PATCH (vs editField, which defers to saveAll)
await updateItem("projects", id, { github: "https://github.com/me/repo" });

// remove — DELETE
await deleteItem("projects", id);

// reorder — PATCH each item's integer `order` to match the new sequence
await reorderItems("projects", ["id-c", "id-a", "id-b"]);

| Op | Timing | Persists via | Notes | | -------------------------------------------- | --------- | ----------------------------------- | ------------------------------------------------------------------ | | editField(collection, id, fieldKey, value) | deferred | (on saveAll) PUT | inline edits; dotted fieldKey for nested values | | saveItem / saveAll | — | PUT {base}/{collection}/{id} | upserts dirty items (uploads pending images first) | | createItem(collection, data, opts?) | immediate | PUT {base}/{collection}/{id} | opts.id to set the id, opts.atStart to prepend; returns the id | | updateItem(collection, id, patch) | immediate | PATCH {base}/{collection}/{id} | programmatic field patch | | deleteItem(collection, id) | immediate | DELETE {base}/{collection}/{id} | | | reorderItems(collection, orderedIds) | immediate | PATCH {base}/{collection}/{id} ×N | writes { order: index } per item |

The immediate ops update items optimistically and roll back on failure (with a notify toast). Order is an int — give the collection a typed order column so it sorts numerically.

The Query language

A neutral, backend-agnostic query passed to DataAdapter.fetchCollection:

type Query = {
  filters?: (QueryFilter | { or: QueryFilter[] })[]; // top level AND; groups OR
  orderBy?: { field: string; direction: "asc" | "desc" }[];
  limit?: number;
  offset?: number;
  populate?: string[]; // reference fields to inline-resolve (see Relations)
};

Operators (QueryFilter = { field, op, value }):

| op | meaning | SQL¹ | Firestore | | --------------------- | -------------------------- | :--: | :-------: | | eq / ne | equals / not equals | ✅ | ✅ | | lt lte gt gte | comparisons | ✅ | ✅ | | in / nin | value in / not in array | ✅ | ✅ | | contains | case-insensitive substring | ✅ | ❌ throws | | { or: [...] } | disjunction | ✅ | ❌ throws |

Where a backend can't honor an op, it throws a clear error rather than returning wrong results. ¹ "SQL" = the shipped PostgresDataAdapter (Setup with Postgres). With a custom DataAdapter, op support is whatever you implement.

await data.fetchCollection("posts", {
  filters: [
    { field: "title", op: "contains", value: "hello" },
    {
      or: [
        { field: "tag", op: "eq", value: "react" },
        { field: "tag", op: "eq", value: "sql" },
      ],
    },
  ],
  orderBy: [{ field: "createdAt", direction: "desc" }],
  limit: 20,
  offset: 40,
});

Relations

References are just fields — a self-describing { collection, id }, a bare id string, or an array of either. Resolve them after a fetch with resolveRelations (engine-side, works over any adapter):

import { resolveRelations } from "@dalgoridim/headless-cms/server";

const posts = await data.fetchCollection("posts");
await resolveRelations(data, posts, {
  populate: ["authorId"],
  relations: { authorId: { collection: "authors" } }, // needed for bare-id refs
});
// each post.authorId is now the resolved author document

Loads are deduplicated and parallelized; unresolved refs are left untouched.

Auth: the gate is yours

AuthIdentity is intentionally minimal and open — only isAdmin is meaningful to the default gate; carry any other claims and authorize on them yourself.

interface AuthIdentity {
  isAdmin: boolean;
  userId?: string;
  email?: string;
  [claim: string]: unknown; // roles, scopes, tenant, …
}

Pass an authorize predicate to gate on whatever you like (defaults to identity.isAdmin === true):

createCmsHandlers({
  data,
  auth,
  authorize: (identity) => identity.role === "editor" || identity.isAdmin,
});

For demos or installations without client auth, use AnonymousEditProvider. It allows local edit-mode toggling but always exposes isAdmin: false, so the server gate remains authoritative and rejects persistence:

import { AnonymousEditProvider } from "@dalgoridim/headless-cms/client";

<AnonymousEditProvider>
  <PageProvider initialItems={initialItems}>{children}</PageProvider>
</AnonymousEditProvider>;

Google sign-in (no Firebase)

auth/google is a Firebase-free way to do "Sign in with Google" — one OAuth client ID, no service account. The server verifies the Google ID token locally against Google's public keys (Node crypto + fetch, zero new deps) and grants admin only to a verified email in your allowlist.

// server gate
import { googleAuth } from "@dalgoridim/headless-cms/auth/google";

export const { GET, PATCH, PUT, DELETE } = createCmsHandlers({
  data,
  auth: googleAuth({
    clientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!,
    adminEmails: process.env.ADMIN_EMAILS!.split(","),
  }),
});
// client — built on @react-oauth/google (install it)
import {
  GoogleAuthProvider,
  GoogleSignInButton,
  useGoogleAuth,
} from "@dalgoridim/headless-cms/auth/google/client";

<GoogleAuthProvider clientId={clientId} adminEmails={["[email protected]"]}>
  <PageProvider …>{children}</PageProvider>
</GoogleAuthProvider>;

// anywhere inside the provider — renders the official Google button:
<GoogleSignInButton onError={() => toast.error("Sign-in failed")} />;
// useGoogleAuth() → { user, isAdmin, isEditing, toggleEdit, logout }

The button writes the ID token to an adminToken cookie that googleAuth reads; isAdmin is optimistic on the client (via adminEmails) with the server gate authoritative. verifyGoogleIdToken(token, { clientId }) is also exported for custom flows.

Your content types stay unconstrained

The engine only adds addressing fields; your domain type T is never forced to declare them:

import type { Editable } from "@dalgoridim/headless-cms";

interface Post {
  title: string;
  body: string;
} // your shape — clean
type StoredPost = Editable<Post>; // Post & { id: string; collection: string }

Styling: bring your own look

The package ships no styles. Skin the primitives in your app — for example, a thin wrapper that applies your design system:

// components/EditableTitle.tsx
import { ContentEditSpan } from "@dalgoridim/headless-cms/client";

export const EditableTitle = (props) => (
  <ContentEditSpan
    {...props}
    className="text-4xl font-bold data-[cms-focused]:ring-2 data-[cms-focused]:ring-blue-500"
  />
);

This keeps your design isolated from the engine and lets the package upgrade without ever changing how your site looks.

Documentation

  • README — this guide: install and usage.
  • ARCHITECTURE.md — design rationale and internals (bundle boundaries, the DataAdapter seam, the unified item model, headless UI).

Build

npm run build      # tsup → dist (esm + cjs + d.ts), per subpath
npm run typecheck  # tsc --noEmit