@dalgoridim/headless-cms
v0.10.0
Published
Database-agnostic, inline-edit headless CMS engine for React / Next.js apps
Downloads
1,984
Maintainers
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-cmsreact / 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 Firebase —
firebase,firebase-admin,cloudinary. - Setup with Postgres —
drizzle-orm,pg,drizzle-kit. The package shipsPostgresDataAdapter, butdrizzle-orm/pgare optional peers (loaded only when you import/adapters/postgres), so you install them yourself. - Other storage:
@aws-sdk/client-s3+@aws-sdk/s3-request-presignerfor S3;@react-oauth/googlefor 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), verifyGoogleIdToken — Firebase-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 neutralQuery.AuthAdapter—verifyRequest(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>) andServerStorageAdapter(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 route — createCmsHandlers({ data, auth }) | identical | identical |
| 4 | Client — PageProvider + 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 cloudinary1. 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-kit1. 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 itDDL 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
dbacross your app? Pass{ db, schema }instead ofconnectionString. To back a different ORM (Prisma, Kysely, …) entirely, implement the seven-methodDataAdapteryourself — 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 withContentEditSpan/EditableImage(deferred — see below), addressing it by thatid. - 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 documentLoads 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
DataAdapterseam, the unified item model, headless UI).
Build
npm run build # tsup → dist (esm + cjs + d.ts), per subpath
npm run typecheck # tsc --noEmit